diff --git a/src/course-lifecycle/components/BlockCommentsPanel.test.tsx b/src/course-lifecycle/components/BlockCommentsPanel.test.tsx new file mode 100644 index 0000000000..cd8b54fb21 --- /dev/null +++ b/src/course-lifecycle/components/BlockCommentsPanel.test.tsx @@ -0,0 +1,147 @@ +import { + fireEvent, initializeMocks, render, screen, +} from '@src/testUtils'; +import { BlockCommentsPanel } from './BlockCommentsPanel'; +import { mockBlockReviewComment } from '../data/api.mock'; + +const mockUseBlockComments = jest.fn(); +const mockCreateMutate = jest.fn(); +const mockResolveMutate = jest.fn(); +const mockDeleteMutate = jest.fn(); + +jest.mock('@src/course-lifecycle/data/apiHooks', () => ({ + useBlockComments: (...args: any[]) => mockUseBlockComments(...args), + useCreateComment: () => ({ mutate: mockCreateMutate, isPending: false }), + useResolveComment: () => ({ mutate: mockResolveMutate, isPending: false }), + useDeleteComment: () => ({ mutate: mockDeleteMutate, isPending: false }), +})); + +const usageKey = 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1'; + +/** Expand the Collapsible panel — its body is not rendered until opened. */ +const expandPanel = () => { + fireEvent.click(screen.getByRole('button', { name: 'Comments' })); +}; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + mockUseBlockComments.mockReturnValue({ data: [], isLoading: false }); + }); + + it('shows loading spinner while fetching comments', () => { + mockUseBlockComments.mockReturnValue({ data: undefined, isLoading: true }); + const { container } = render(); + expandPanel(); + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('shows "No comments yet." when comments list is empty', () => { + render(); + expandPanel(); + expect(screen.getByText('No comments yet.')).toBeInTheDocument(); + }); + + it('renders comment author and text', () => { + const comment = mockBlockReviewComment({ author: 'reviewer', comment: 'Please fix this issue' }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByText('reviewer')).toBeInTheDocument(); + expect(screen.getByText('Please fix this issue')).toBeInTheDocument(); + }); + + it('shows "Resolve" button for unresolved comment', () => { + const comment = mockBlockReviewComment({ resolved: false }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByRole('button', { name: 'Resolve' })).toBeInTheDocument(); + }); + + it('shows "Resolved" badge and no Resolve button for resolved comment', () => { + const comment = mockBlockReviewComment({ resolved: true }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByText('Resolved')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Resolve' })).not.toBeInTheDocument(); + }); + + it('shows "Delete" button for comment authored by the current user (abc123)', () => { + // Default initializeMocks user is { username: 'abc123' } + const comment = mockBlockReviewComment({ author: 'abc123' }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('does not show "Delete" button for comment authored by another user', () => { + const comment = mockBlockReviewComment({ author: 'some_other_user' }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + it('calls resolveMutation.mutate with comment id when Resolve is clicked', () => { + const comment = mockBlockReviewComment({ id: 42, resolved: false }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Resolve' })); + expect(mockResolveMutate).toHaveBeenCalledWith(42); + }); + + it('calls deleteMutation.mutate with comment id when Delete is clicked', () => { + const comment = mockBlockReviewComment({ id: 42, author: 'abc123' }); + mockUseBlockComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + expect(mockDeleteMutate).toHaveBeenCalledWith(42); + }); + + it('calls createMutation.mutate with trimmed text when Add Comment is clicked', () => { + render(); + expandPanel(); + const textarea = screen.getByPlaceholderText('Add a comment...'); + fireEvent.change(textarea, { target: { value: ' Review this block ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add Comment' })); + expect(mockCreateMutate).toHaveBeenCalledWith('Review this block', expect.any(Object)); + }); + + it('"Add Comment" button is disabled when textarea is empty', () => { + render(); + expandPanel(); + expect(screen.getByRole('button', { name: 'Add Comment' })).toBeDisabled(); + }); + + it('"Add Comment" button becomes enabled when textarea has content', () => { + render(); + expandPanel(); + const textarea = screen.getByPlaceholderText('Add a comment...'); + fireEvent.change(textarea, { target: { value: 'A new comment' } }); + expect(screen.getByRole('button', { name: 'Add Comment' })).not.toBeDisabled(); + }); + + it('hides the Add Comment form in readOnly mode', () => { + render(); + expandPanel(); + expect(screen.queryByPlaceholderText('Add a comment...')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Add Comment' })).not.toBeInTheDocument(); + }); + + it('renders multiple comments in the panel body', () => { + const comments = [ + mockBlockReviewComment({ id: 1, comment: 'First comment' }), + mockBlockReviewComment({ id: 2, comment: 'Second comment' }), + ]; + mockUseBlockComments.mockReturnValue({ data: comments, isLoading: false }); + render(); + expandPanel(); + expect(screen.getByText('First comment')).toBeInTheDocument(); + expect(screen.getByText('Second comment')).toBeInTheDocument(); + }); +}); diff --git a/src/course-lifecycle/components/BlockCommentsPanel.tsx b/src/course-lifecycle/components/BlockCommentsPanel.tsx new file mode 100644 index 0000000000..fa35edf980 --- /dev/null +++ b/src/course-lifecycle/components/BlockCommentsPanel.tsx @@ -0,0 +1,112 @@ +import { useState } from 'react'; +import { + Badge, + Button, + Collapsible, + Form, + Spinner, +} from '@openedx/paragon'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import { + useBlockComments, useCreateComment, useDeleteComment, useResolveComment, +} from '../data/apiHooks'; + +interface Props { + usageKey: string; + readOnly?: boolean; +} + +export const BlockCommentsPanel = ({ usageKey, readOnly = false }: Props) => { + const [newComment, setNewComment] = useState(''); + const currentUsername = getAuthenticatedUser()?.username; + const { data: comments, isLoading } = useBlockComments(usageKey); + const createMutation = useCreateComment(usageKey); + const resolveMutation = useResolveComment(usageKey); + const deleteMutation = useDeleteComment(usageKey); + + const handleAdd = () => { + if (!newComment.trim()) { + return; + } + createMutation.mutate(newComment.trim(), { + onSuccess: () => setNewComment(''), + }); + }; + + return ( + + + Comments + {comments && comments.length > 0 && ( + {comments.length} + )} + + + {isLoading && } + {comments && comments.length === 0 && ( +

No comments yet.

+ )} + {comments?.map((c) => ( +
+
+ {c.author} +
+ {c.resolved && Resolved} + {!c.resolved && ( + + )} + {c.author === currentUsername && ( + + )} +
+
+

{c.comment}

+ {new Date(c.created).toLocaleDateString()} +
+ ))} + {!readOnly && ( + <> + + setNewComment(e.target.value)} + /> + + + + )} +
+
+ ); +}; diff --git a/src/course-lifecycle/components/CourseCommentsPanel.test.tsx b/src/course-lifecycle/components/CourseCommentsPanel.test.tsx new file mode 100644 index 0000000000..15a730aabf --- /dev/null +++ b/src/course-lifecycle/components/CourseCommentsPanel.test.tsx @@ -0,0 +1,134 @@ +import { + fireEvent, initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; +import { CourseCommentsPanel } from './CourseCommentsPanel'; +import { mockBlockReviewComment } from '../data/api.mock'; + +const mockUseCourseComments = jest.fn(); +const mockCreateCourseMutate = jest.fn(); +const mockResolveComment = jest.fn(); +const mockDeleteComment = jest.fn(); + +jest.mock('@src/course-lifecycle/data/apiHooks', () => ({ + useCourseComments: (...args: any[]) => mockUseCourseComments(...args), + useCreateCourseComment: () => ({ mutate: mockCreateCourseMutate, isPending: false }), + lifecycleQueryKeys: { + courseComments: (id: string) => ['lifecycle', 'course', id, 'comments'], + }, +})); + +jest.mock('@src/course-lifecycle/data/api', () => ({ + resolveComment: (...args: any[]) => mockResolveComment(...args), + deleteComment: (...args: any[]) => mockDeleteComment(...args), +})); + +const courseId = 'course-v1:TestOrg+TestCourse+2025_T1'; + +/** Expand the Collapsible panel — its body is not rendered until opened. */ +const expandPanel = () => { + fireEvent.click(screen.getByRole('button', { name: 'Comments' })); +}; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + mockUseCourseComments.mockReturnValue({ data: [], isLoading: false }); + mockResolveComment.mockResolvedValue({ id: 1, resolved: true }); + mockDeleteComment.mockResolvedValue(undefined); + }); + + it('shows loading spinner while fetching comments', () => { + mockUseCourseComments.mockReturnValue({ data: undefined, isLoading: true }); + const { container } = render(); + expandPanel(); + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('shows "No comments yet." when comments list is empty', () => { + render(); + expandPanel(); + expect(screen.getByText('No comments yet.')).toBeInTheDocument(); + }); + + it('renders comment author and text', () => { + const comment = mockBlockReviewComment({ author: 'admin', comment: 'Course needs more examples' }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByText('admin')).toBeInTheDocument(); + expect(screen.getByText('Course needs more examples')).toBeInTheDocument(); + }); + + it('shows "Resolve" button for unresolved comment', () => { + const comment = mockBlockReviewComment({ resolved: false }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByRole('button', { name: 'Resolve' })).toBeInTheDocument(); + }); + + it('shows "Resolved" badge and no Resolve button for resolved comment', () => { + const comment = mockBlockReviewComment({ resolved: true }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByText('Resolved')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Resolve' })).not.toBeInTheDocument(); + }); + + it('shows "Delete" button for comment authored by the current user', () => { + const comment = mockBlockReviewComment({ author: 'abc123' }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument(); + }); + + it('does not show "Delete" button for comment authored by another user', () => { + const comment = mockBlockReviewComment({ author: 'another_user' }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument(); + }); + + it('calls resolveComment api when Resolve is clicked', async () => { + const comment = mockBlockReviewComment({ id: 7, resolved: false }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Resolve' })); + await waitFor(() => expect(mockResolveComment).toHaveBeenCalledWith(7)); + }); + + it('calls deleteComment api when Delete is clicked', async () => { + const comment = mockBlockReviewComment({ id: 7, author: 'abc123' }); + mockUseCourseComments.mockReturnValue({ data: [comment], isLoading: false }); + render(); + expandPanel(); + fireEvent.click(screen.getByRole('button', { name: 'Delete' })); + await waitFor(() => expect(mockDeleteComment).toHaveBeenCalledWith(7)); + }); + + it('calls createMutation.mutate with trimmed text when Add Comment is clicked', () => { + render(); + expandPanel(); + const textarea = screen.getByPlaceholderText('Add a comment...'); + fireEvent.change(textarea, { target: { value: ' Course feedback ' } }); + fireEvent.click(screen.getByRole('button', { name: 'Add Comment' })); + expect(mockCreateCourseMutate).toHaveBeenCalledWith('Course feedback', expect.any(Object)); + }); + + it('"Add Comment" button is disabled when textarea is empty', () => { + render(); + expandPanel(); + expect(screen.getByRole('button', { name: 'Add Comment' })).toBeDisabled(); + }); + + it('always shows the Add Comment form (no readOnly mode)', () => { + render(); + expandPanel(); + expect(screen.getByPlaceholderText('Add a comment...')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Add Comment' })).toBeInTheDocument(); + }); +}); diff --git a/src/course-lifecycle/components/CourseCommentsPanel.tsx b/src/course-lifecycle/components/CourseCommentsPanel.tsx new file mode 100644 index 0000000000..3b9f7beb55 --- /dev/null +++ b/src/course-lifecycle/components/CourseCommentsPanel.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react'; +import { + Badge, + Button, + Collapsible, + Form, + Spinner, +} from '@openedx/paragon'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; + +import { deleteComment, resolveComment } from '../data/api'; +import { lifecycleQueryKeys, useCourseComments, useCreateCourseComment } from '../data/apiHooks'; + +interface Props { + courseId: string; +} + +export const CourseCommentsPanel = ({ courseId }: Props) => { + const [newComment, setNewComment] = useState(''); + const queryClient = useQueryClient(); + const currentUsername = getAuthenticatedUser()?.username; + const { data: comments, isLoading } = useCourseComments(courseId); + const createMutation = useCreateCourseComment(courseId); + + const resolveMutation = useMutation({ + mutationFn: (commentId: number) => resolveComment(commentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseComments(courseId) }); + }, + }); + + const deleteMutation = useMutation({ + mutationFn: (commentId: number) => deleteComment(commentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseComments(courseId) }); + }, + }); + + const handleAdd = () => { + if (!newComment.trim()) { + return; + } + createMutation.mutate(newComment.trim(), { + onSuccess: () => setNewComment(''), + }); + }; + + return ( + + + Comments + {comments && comments.length > 0 && ( + {comments.length} + )} + + + {isLoading && } + {comments && comments.length === 0 && ( +

No comments yet.

+ )} + {comments?.map((c) => ( +
+
+ {c.author} +
+ {c.resolved && Resolved} + {!c.resolved && ( + + )} + {c.author === currentUsername && ( + + )} +
+
+

{c.comment}

+ {new Date(c.created).toLocaleDateString()} +
+ ))} + + setNewComment(e.target.value)} + /> + + +
+
+ ); +}; diff --git a/src/course-lifecycle/components/CourseLifecycleSection.test.tsx b/src/course-lifecycle/components/CourseLifecycleSection.test.tsx new file mode 100644 index 0000000000..bf1c5f2c76 --- /dev/null +++ b/src/course-lifecycle/components/CourseLifecycleSection.test.tsx @@ -0,0 +1,205 @@ +import { + fireEvent, initializeMocks, render, screen, +} from '@src/testUtils'; +import { CourseLifecycleSection } from './CourseLifecycleSection'; +import { mockCourseAggregateState } from '../data/api.mock'; + +const mockUseCourseAggregateState = jest.fn(); +const mockSubmitCourseMutate = jest.fn(); +const mockApproveCourseMutate = jest.fn(); +const mockRequestCourseChangesMutate = jest.fn(); +const mockPublishCourseMutate = jest.fn(); + +jest.mock('@src/course-lifecycle/data/apiHooks', () => ({ + useCourseAggregateState: (...args: any[]) => mockUseCourseAggregateState(...args), + useSubmitCourseForReview: () => ({ mutate: mockSubmitCourseMutate, isPending: false }), + useApproveCourse: () => ({ mutate: mockApproveCourseMutate, isPending: false }), + useRequestCourseChanges: () => ({ mutate: mockRequestCourseChangesMutate, isPending: false }), + usePublishCourse: () => ({ mutate: mockPublishCourseMutate, isPending: false }), + // useCourseComments + useCreateCourseComment needed by CourseCommentsPanel child + useCourseComments: () => ({ data: [], isLoading: false }), + useCreateCourseComment: () => ({ mutate: jest.fn(), isPending: false }), + lifecycleQueryKeys: { + courseComments: (id: string) => ['lifecycle', 'course', id, 'comments'], + }, +})); + +jest.mock('@src/course-lifecycle/data/api', () => ({ + resolveComment: jest.fn().mockResolvedValue({}), + deleteComment: jest.fn().mockResolvedValue(undefined), +})); + +jest.mock('@src/course-outline/data/thunk', () => ({ + fetchCourseOutlineIndexQuery: jest.fn(() => ({ type: 'MOCK_FETCH_OUTLINE' })), +})); + +const courseId = 'course-v1:TestOrg+TestCourse+2025_T1'; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + mockUseCourseAggregateState.mockReturnValue({ data: undefined, isLoading: false, error: null }); + }); + + it('shows loading spinner while data is loading', () => { + mockUseCourseAggregateState.mockReturnValue({ data: undefined, isLoading: true, error: null }); + const { container } = render(); + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('shows "not enrolled in review workflow" when there is an error', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error('Not found'), + }); + render(); + expect(screen.getByText(/not enrolled in the review workflow/i)).toBeInTheDocument(); + }); + + it('shows "not enrolled in review workflow" when data is null', () => { + mockUseCourseAggregateState.mockReturnValue({ data: null, isLoading: false, error: null }); + render(); + expect(screen.getByText(/not enrolled in the review workflow/i)).toBeInTheDocument(); + }); + + it('renders LifecycleBadge with the course aggregate state', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ aggregateState: 'for_review' }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByText('Submitted for Review')).toBeInTheDocument(); + }); + + it('renders block count breakdown badges for non-zero states', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ + blockCounts: { + draft: 3, for_review: 1, approved: 2, published: 0, + }, + }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByText('3 Draft')).toBeInTheDocument(); + expect(screen.getByText('1 For Review')).toBeInTheDocument(); + expect(screen.getByText('2 Approved')).toBeInTheDocument(); + }); + + it('does not render block count badge for states with zero count', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ + blockCounts: { + draft: 0, for_review: 0, approved: 0, published: 5, + }, + }), + isLoading: false, + error: null, + }); + render(); + expect(screen.queryByText(/0 Draft/)).not.toBeInTheDocument(); + expect(screen.queryByText(/0 For Review/)).not.toBeInTheDocument(); + expect(screen.queryByText(/0 Approved/)).not.toBeInTheDocument(); + }); + + it('renders "Submit All for Review" button when canSubmit=true', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: true }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByRole('button', { name: 'Submit All for Review' })).toBeInTheDocument(); + }); + + it('renders "Approve All" button when canApprove=true', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: false, canApprove: true }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByRole('button', { name: 'Approve All' })).toBeInTheDocument(); + }); + + it('renders "Request Changes" button when canRequestChanges=true', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: false, canRequestChanges: true }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByRole('button', { name: 'Request Changes' })).toBeInTheDocument(); + }); + + it('renders "Publish Course" button when canPublish=true', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: false, canPublish: true }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByRole('button', { name: 'Publish Course' })).toBeInTheDocument(); + }); + + it('does not render action buttons when no action is allowed', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ + canSubmit: false, canApprove: false, canRequestChanges: false, canPublish: false, + }), + isLoading: false, + error: null, + }); + render(); + expect(screen.queryByRole('button', { name: 'Submit All for Review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve All' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Publish Course' })).not.toBeInTheDocument(); + }); + + it('calls submitMutation.mutate() when Submit All for Review is clicked', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: true }), + isLoading: false, + error: null, + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Submit All for Review' })); + expect(mockSubmitCourseMutate).toHaveBeenCalledTimes(1); + }); + + it('calls approveMutation.mutate() when Approve All is clicked', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: false, canApprove: true }), + isLoading: false, + error: null, + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Approve All' })); + expect(mockApproveCourseMutate).toHaveBeenCalledTimes(1); + }); + + it('calls publishMutation.mutate() when Publish Course is clicked', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState({ canSubmit: false, canPublish: true }), + isLoading: false, + error: null, + }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Publish Course' })); + expect(mockPublishCourseMutate).toHaveBeenCalledTimes(1); + }); + + it('renders the CourseCommentsPanel when data is loaded', () => { + mockUseCourseAggregateState.mockReturnValue({ + data: mockCourseAggregateState(), + isLoading: false, + error: null, + }); + render(); + // CourseCommentsPanel renders its trigger with "Comments" text + expect(screen.getByText('Comments')).toBeInTheDocument(); + }); +}); diff --git a/src/course-lifecycle/components/CourseLifecycleSection.tsx b/src/course-lifecycle/components/CourseLifecycleSection.tsx new file mode 100644 index 0000000000..b009aed72b --- /dev/null +++ b/src/course-lifecycle/components/CourseLifecycleSection.tsx @@ -0,0 +1,146 @@ +import { useEffect, useRef } from 'react'; +import { Badge, Button, Spinner } from '@openedx/paragon'; +import { useDispatch } from 'react-redux'; + +import { fetchCourseOutlineIndexQuery } from '@src/course-outline/data/thunk'; +import { + useCourseAggregateState, + useSubmitCourseForReview, + useApproveCourse, + useRequestCourseChanges, + usePublishCourse, +} from '../data/apiHooks'; +import type { LifecycleState } from '../data/types'; +import { LifecycleBadge } from './LifecycleBadge'; +import { CourseCommentsPanel } from './CourseCommentsPanel'; + +// Only surface non-published states in the block count breakdown. +// Published is the expected clean state and doesn't need to be called out. +const BREAKDOWN_STATES: LifecycleState[] = ['draft', 'for_review', 'approved']; + +const STATE_LABELS: Record = { + draft: 'Draft', + for_review: 'For Review', + approved: 'Approved', + published: 'Published', +}; + +interface Props { + courseId: string; +} + +export const CourseLifecycleSection = ({ courseId }: Props) => { + const dispatch = useDispatch(); + const { data, isLoading, error } = useCourseAggregateState(courseId); + const submitMutation = useSubmitCourseForReview(courseId); + const approveMutation = useApproveCourse(courseId); + const requestChangesMutation = useRequestCourseChanges(courseId); + const publishMutation = usePublishCourse(courseId); + + // Reactively refresh the Redux outline when the course aggregate state transitions to + // 'published'. Using useEffect instead of a mutation callback avoids stale-closure + // issues caused by TanStack Query capturing useMutation options at hook-init time. + const prevAggStateRef = useRef(undefined); + useEffect(() => { + const curr = data?.aggregateState; + if ( + prevAggStateRef.current !== undefined + && prevAggStateRef.current !== 'published' + && curr === 'published' + ) { + dispatch(fetchCourseOutlineIndexQuery(courseId)); + } + prevAggStateRef.current = curr; + }, [data?.aggregateState]); + + return ( +
+ {isLoading && } + {!isLoading && (error || !data) && ( +

This course is not enrolled in the review workflow.

+ )} + {!isLoading && data && ( + <> +

Current Status

+ {data.aggregateState ? ( + + ) : ( +

Not tracked

+ )} + + {BREAKDOWN_STATES.some((s) => !!data.blockCounts[s]) && ( + <> +

Block Breakdown

+
+ {BREAKDOWN_STATES.map((state) => { + const count = data.blockCounts[state]; + if (!count) { + return null; + } + return ( + + {`${count} ${STATE_LABELS[state]}`} + + ); + })} +
+ + )} + + {(data.canSubmit || data.canApprove || data.canRequestChanges || data.canPublish) && ( + <> +

Actions

+
+ {data.canSubmit && ( + + )} + {data.canApprove && ( + + )} + {data.canRequestChanges && ( + + )} + {data.canPublish && ( + + )} +
+ + )} + + + )} +
+ ); +}; diff --git a/src/course-lifecycle/components/LifecycleActionButtons.test.tsx b/src/course-lifecycle/components/LifecycleActionButtons.test.tsx new file mode 100644 index 0000000000..b804824080 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleActionButtons.test.tsx @@ -0,0 +1,108 @@ +import { + fireEvent, initializeMocks, render, screen, +} from '@src/testUtils'; +import { LifecycleActionButtons } from './LifecycleActionButtons'; +import { mockBlockReviewState } from '../data/api.mock'; + +const mockSubmitMutate = jest.fn(); +const mockApproveMutate = jest.fn(); +const mockRequestChangesMutate = jest.fn(); +const mockPublishMutate = jest.fn(); + +jest.mock('@src/course-lifecycle/data/apiHooks', () => ({ + useSubmitForReview: () => ({ mutate: mockSubmitMutate, isPending: false }), + useApproveBlock: () => ({ mutate: mockApproveMutate, isPending: false }), + useRequestChanges: () => ({ mutate: mockRequestChangesMutate, isPending: false }), + usePublishBlock: () => ({ mutate: mockPublishMutate, isPending: false }), +})); + +const usageKey = 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1'; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders no buttons when all capability flags are false', () => { + const blockState = mockBlockReviewState({ + canSubmit: false, canApprove: false, canRequestChanges: false, canPublish: false, + }); + render(); + expect(screen.queryByRole('button', { name: 'Submit for Review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Request Changes' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Publish' })).not.toBeInTheDocument(); + }); + + it('renders no buttons when hasChanges=false and canPublish=false', () => { + const blockState = mockBlockReviewState({ + canSubmit: true, canApprove: false, canRequestChanges: false, canPublish: false, + }); + render(); + expect(screen.queryByRole('button', { name: 'Submit for Review' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Publish' })).not.toBeInTheDocument(); + }); + + it('renders "Submit for Review" button when canSubmit=true', () => { + const blockState = mockBlockReviewState({ canSubmit: true }); + render(); + expect(screen.getByRole('button', { name: 'Submit for Review' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Request Changes' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Publish' })).not.toBeInTheDocument(); + }); + + it('renders "Approve" button when canApprove=true', () => { + const blockState = mockBlockReviewState({ canSubmit: false, canApprove: true }); + render(); + expect(screen.getByRole('button', { name: 'Approve' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Submit for Review' })).not.toBeInTheDocument(); + }); + + it('renders "Request Changes" button when canRequestChanges=true', () => { + const blockState = mockBlockReviewState({ canSubmit: false, canRequestChanges: true }); + render(); + expect(screen.getByRole('button', { name: 'Request Changes' })).toBeInTheDocument(); + }); + + it('renders "Publish" button when canPublish=true', () => { + const blockState = mockBlockReviewState({ canSubmit: false, canPublish: true }); + render(); + expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument(); + }); + + it('hides workflow buttons but shows Publish when hasChanges=false and canPublish=true', () => { + const blockState = mockBlockReviewState({ canSubmit: true, canPublish: true }); + render(); + expect(screen.queryByRole('button', { name: 'Submit for Review' })).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Publish' })).toBeInTheDocument(); + }); + + it('calls submitMutation.mutate() when Submit for Review button is clicked', () => { + const blockState = mockBlockReviewState({ canSubmit: true }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Submit for Review' })); + expect(mockSubmitMutate).toHaveBeenCalledTimes(1); + }); + + it('calls approveMutation.mutate() when Approve button is clicked', () => { + const blockState = mockBlockReviewState({ canSubmit: false, canApprove: true }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Approve' })); + expect(mockApproveMutate).toHaveBeenCalledTimes(1); + }); + + it('calls requestChangesMutation.mutate() when Request Changes button is clicked', () => { + const blockState = mockBlockReviewState({ canSubmit: false, canRequestChanges: true }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Request Changes' })); + expect(mockRequestChangesMutate).toHaveBeenCalledTimes(1); + }); + + it('calls publishMutation.mutate() when Publish button is clicked', () => { + const blockState = mockBlockReviewState({ canSubmit: false, canPublish: true }); + render(); + fireEvent.click(screen.getByRole('button', { name: 'Publish' })); + expect(mockPublishMutate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-lifecycle/components/LifecycleActionButtons.tsx b/src/course-lifecycle/components/LifecycleActionButtons.tsx new file mode 100644 index 0000000000..a8c2563797 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleActionButtons.tsx @@ -0,0 +1,91 @@ +import { Button, Spinner } from '@openedx/paragon'; + +import type { BlockReviewState } from '../data/types'; +import { + useApproveBlock, + usePublishBlock, + useRequestChanges, + useSubmitForReview, +} from '../data/apiHooks'; + +interface Props { + usageKey: string; + blockState: BlockReviewState; + hasChanges?: boolean; + /** Called after a successful lifecycle publish — used by the unit page to refresh its Redux state. */ + onPublishSuccess?: () => void; +} + +export const LifecycleActionButtons = ({ + usageKey, blockState, hasChanges, onPublishSuccess, +}: Props) => { + const submitMutation = useSubmitForReview(usageKey); + const approveMutation = useApproveBlock(usageKey); + const requestChangesMutation = useRequestChanges(usageKey); + const publishMutation = usePublishBlock(usageKey, { onSuccess: onPublishSuccess }); + + const { + canSubmit, canApprove, canRequestChanges, canPublish, + } = blockState; + + // When there are no pending changes, workflow buttons (submit/approve/request-changes) + // are irrelevant, but the Publish button must still show if the block is approved. + const showWorkflowButtons = hasChanges !== false; + + if (!showWorkflowButtons && !canPublish) { + return null; + } + + if (!canSubmit && !canApprove && !canRequestChanges && !canPublish) { + return null; + } + + return ( +
+ {showWorkflowButtons && canSubmit && ( + + )} + {showWorkflowButtons && canApprove && ( + + )} + {showWorkflowButtons && canRequestChanges && ( + + )} + {canPublish && ( + + )} +
+ ); +}; diff --git a/src/course-lifecycle/components/LifecycleBadge.test.tsx b/src/course-lifecycle/components/LifecycleBadge.test.tsx new file mode 100644 index 0000000000..9a50496a95 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleBadge.test.tsx @@ -0,0 +1,33 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import { LifecycleBadge } from './LifecycleBadge'; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + }); + + it('renders "Draft" badge for draft state', () => { + render(); + expect(screen.getByText('Draft')).toBeInTheDocument(); + }); + + it('renders "Submitted for Review" badge for for_review state', () => { + render(); + expect(screen.getByText('Submitted for Review')).toBeInTheDocument(); + }); + + it('renders "Approved" badge for approved state', () => { + render(); + expect(screen.getByText('Approved')).toBeInTheDocument(); + }); + + it('renders "Published" badge for published state', () => { + render(); + expect(screen.getByText('Published')).toBeInTheDocument(); + }); + + it('adds lifecycle-badge class to every state', () => { + const { container } = render(); + expect(container.querySelector('.lifecycle-badge')).toBeInTheDocument(); + }); +}); diff --git a/src/course-lifecycle/components/LifecycleBadge.tsx b/src/course-lifecycle/components/LifecycleBadge.tsx new file mode 100644 index 0000000000..135dbc0a62 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleBadge.tsx @@ -0,0 +1,23 @@ +import { Badge } from '@openedx/paragon'; + +import type { LifecycleState } from '../data/types'; + +const STATE_CONFIG: Record = { + draft: { label: 'Draft', variant: 'secondary' }, + for_review: { label: 'Submitted for Review', variant: 'warning' }, + approved: { label: 'Approved', variant: 'info' }, + published: { label: 'Published', variant: 'success' }, +}; + +interface Props { + state: LifecycleState; +} + +export const LifecycleBadge = ({ state }: Props) => { + const { label, variant } = STATE_CONFIG[state] ?? STATE_CONFIG.draft; + return ( + + {label} + + ); +}; diff --git a/src/course-lifecycle/components/LifecycleModal.test.tsx b/src/course-lifecycle/components/LifecycleModal.test.tsx new file mode 100644 index 0000000000..4111481d8d --- /dev/null +++ b/src/course-lifecycle/components/LifecycleModal.test.tsx @@ -0,0 +1,118 @@ +import { + fireEvent, initializeMocks, render, screen, +} from '@src/testUtils'; +import { LifecycleModal } from './LifecycleModal'; +import { mockBlockReviewState } from '../data/api.mock'; + +const mockUseBlockState = jest.fn(); + +// Mocks for LifecycleActionButtons child +const mockSubmitMutate = jest.fn(); +const mockApproveMutate = jest.fn(); +const mockRequestChangesMutate = jest.fn(); +const mockPublishMutate = jest.fn(); + +// Mocks for BlockCommentsPanel child +const mockUseBlockComments = jest.fn(); +const mockCreateMutate = jest.fn(); +const mockResolveMutate = jest.fn(); +const mockDeleteMutate = jest.fn(); + +jest.mock('@src/course-lifecycle/data/apiHooks', () => ({ + useBlockState: (...args: any[]) => mockUseBlockState(...args), + lifecycleQueryKeys: { + blockState: (key: string) => ['lifecycle', 'block', key, 'state'], + }, + useSubmitForReview: () => ({ mutate: mockSubmitMutate, isPending: false }), + useApproveBlock: () => ({ mutate: mockApproveMutate, isPending: false }), + useRequestChanges: () => ({ mutate: mockRequestChangesMutate, isPending: false }), + usePublishBlock: () => ({ mutate: mockPublishMutate, isPending: false }), + useBlockComments: (...args: any[]) => mockUseBlockComments(...args), + useCreateComment: () => ({ mutate: mockCreateMutate, isPending: false }), + useResolveComment: () => ({ mutate: mockResolveMutate, isPending: false }), + useDeleteComment: () => ({ mutate: mockDeleteMutate, isPending: false }), +})); + +const blockId = 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1'; +const displayName = 'Introduction Unit'; + +describe('', () => { + beforeEach(() => { + initializeMocks(); + mockUseBlockState.mockReturnValue({ data: undefined, isLoading: false, error: null }); + mockUseBlockComments.mockReturnValue({ data: [], isLoading: false }); + }); + + it('does not render modal content when isOpen=false', () => { + render( + , + ); + expect(screen.queryByText(`Review Status: ${displayName}`)).not.toBeInTheDocument(); + }); + + it('renders modal title when isOpen=true', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'draft' }), + isLoading: false, + error: null, + }); + render( + , + ); + expect(screen.getByText(`Review Status: ${displayName}`)).toBeInTheDocument(); + }); + + it('shows loading message while block state is fetching', () => { + mockUseBlockState.mockReturnValue({ data: undefined, isLoading: true, error: null }); + render( + , + ); + expect(screen.getByText('Loading review status...')).toBeInTheDocument(); + }); + + it('shows "not in review workflow" message when blockState is null', () => { + mockUseBlockState.mockReturnValue({ data: null, isLoading: false, error: null }); + render( + , + ); + expect(screen.getByText('This block is not in the review workflow.')).toBeInTheDocument(); + }); + + it('renders LifecycleBadge with state label when block state is loaded', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'approved' }), + isLoading: false, + error: null, + }); + render( + , + ); + expect(screen.getByText('Approved')).toBeInTheDocument(); + }); + + it('shows effectiveState as "published" when hasChanges=false and state=draft', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'draft' }), + isLoading: false, + error: null, + }); + render( + , + ); + expect(screen.getByText('Published')).toBeInTheDocument(); + }); + + it('calls onClose when the modal close button is clicked', () => { + const onClose = jest.fn(); + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'draft', canSubmit: false }), + isLoading: false, + error: null, + }); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: /close/i })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/course-lifecycle/components/LifecycleModal.tsx b/src/course-lifecycle/components/LifecycleModal.tsx new file mode 100644 index 0000000000..6f13bbaf21 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleModal.tsx @@ -0,0 +1,60 @@ +import { ModalDialog } from '@openedx/paragon'; + +import { LifecycleBadge } from './LifecycleBadge'; +import { LifecycleActionButtons } from './LifecycleActionButtons'; +import { BlockCommentsPanel } from './BlockCommentsPanel'; +import { useBlockState } from '../data/apiHooks'; + +interface Props { + isOpen: boolean; + onClose: () => void; + blockId: string; + displayName: string; + hasChanges?: boolean; + /** Called after a successful lifecycle publish — used to refresh Redux outline state. */ + onPublishSuccess?: () => void; +} + +export const LifecycleModal = ({ + isOpen, onClose, blockId, displayName, hasChanges, onPublishSuccess, +}: Props) => { + const { data: blockState, isLoading } = useBlockState(blockId); + + const effectiveState = (!hasChanges && blockState?.state === 'draft') ? 'published' : blockState?.state; + + return ( + + + + {`Review Status: ${displayName}`} + + + + {isLoading &&

Loading review status...

} + {!isLoading && !blockState && ( +

This block is not in the review workflow.

+ )} + {!isLoading && blockState && effectiveState && ( + <> + + { onPublishSuccess?.(); onClose(); }} + /> + + + )} +
+
+ ); +}; diff --git a/src/course-lifecycle/components/LifecycleSection.test.tsx b/src/course-lifecycle/components/LifecycleSection.test.tsx new file mode 100644 index 0000000000..8ffdcf5747 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleSection.test.tsx @@ -0,0 +1,132 @@ +import { + initializeMocks, render, screen, waitFor, +} from '@src/testUtils'; +import type { QueryClient } from '@tanstack/react-query'; +import { LifecycleSection } from './LifecycleSection'; +import { mockBlockReviewState } from '../data/api.mock'; + +const mockUseBlockState = jest.fn(); + +// Mocks for LifecycleActionButtons child +const mockSubmitMutate = jest.fn(); +const mockApproveMutate = jest.fn(); +const mockRequestChangesMutate = jest.fn(); +const mockPublishMutate = jest.fn(); + +// Mocks for BlockCommentsPanel child +const mockUseBlockComments = jest.fn(); +const mockCreateMutate = jest.fn(); +const mockResolveMutate = jest.fn(); +const mockDeleteMutate = jest.fn(); + +// Inline the lifecycleQueryKeys value — plain objects cannot be referenced before init in hoisted mocks +jest.mock('@src/course-lifecycle/data/apiHooks', () => ({ + useBlockState: (...args: any[]) => mockUseBlockState(...args), + lifecycleQueryKeys: { + blockState: (key: string) => ['lifecycle', 'block', key, 'state'], + }, + useSubmitForReview: () => ({ mutate: mockSubmitMutate, isPending: false }), + useApproveBlock: () => ({ mutate: mockApproveMutate, isPending: false }), + useRequestChanges: () => ({ mutate: mockRequestChangesMutate, isPending: false }), + usePublishBlock: () => ({ mutate: mockPublishMutate, isPending: false }), + useBlockComments: (...args: any[]) => mockUseBlockComments(...args), + useCreateComment: () => ({ mutate: mockCreateMutate, isPending: false }), + useResolveComment: () => ({ mutate: mockResolveMutate, isPending: false }), + useDeleteComment: () => ({ mutate: mockDeleteMutate, isPending: false }), +})); + +const usageKey = 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1'; + +let queryClient: QueryClient; + +describe('', () => { + beforeEach(() => { + const mocks = initializeMocks(); + queryClient = mocks.queryClient; + mockUseBlockState.mockReturnValue({ data: undefined, isLoading: false, error: null }); + mockUseBlockComments.mockReturnValue({ data: [], isLoading: false }); + }); + + it('shows loading spinner while block state is loading', () => { + mockUseBlockState.mockReturnValue({ data: undefined, isLoading: true, error: null }); + const { container } = render(); + expect(container.querySelector('.spinner-border')).toBeInTheDocument(); + }); + + it('shows "Not in review workflow" when API returns 404', () => { + mockUseBlockState.mockReturnValue({ + data: undefined, + isLoading: false, + error: { response: { status: 404 } }, + }); + render(); + expect(screen.getByText('Not in review workflow')).toBeInTheDocument(); + }); + + it('shows "Could not load review status" for non-404 errors', () => { + mockUseBlockState.mockReturnValue({ + data: undefined, + isLoading: false, + error: { response: { status: 500 } }, + }); + render(); + expect(screen.getByText('Could not load review status')).toBeInTheDocument(); + }); + + it('renders the LifecycleBadge with state label when block state is loaded', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'for_review' }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByText('Submitted for Review')).toBeInTheDocument(); + }); + + it('shows custom title prop', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'draft' }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByText('My Review Panel')).toBeInTheDocument(); + }); + + it('shows effectiveState as "published" when hasChanges=false and state is draft', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'draft' }), + isLoading: false, + error: null, + }); + render(); + // When there are no changes and state is draft, block is effectively published + expect(screen.getByText('Published')).toBeInTheDocument(); + }); + + it('shows draft state when hasChanges=true even if state is draft', () => { + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'draft' }), + isLoading: false, + error: null, + }); + render(); + expect(screen.getByText('Draft')).toBeInTheDocument(); + }); + + it('invalidates block state cache when hasChanges changes to true', async () => { + jest.spyOn(queryClient, 'invalidateQueries'); + mockUseBlockState.mockReturnValue({ + data: mockBlockReviewState({ state: 'published' }), + isLoading: false, + error: null, + }); + const { rerender } = render(); + rerender(); + await waitFor(() => { + expect(queryClient.invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['lifecycle', 'block', usageKey, 'state'], + }); + }); + }); +}); diff --git a/src/course-lifecycle/components/LifecycleSection.tsx b/src/course-lifecycle/components/LifecycleSection.tsx new file mode 100644 index 0000000000..9aa5d1f4db --- /dev/null +++ b/src/course-lifecycle/components/LifecycleSection.tsx @@ -0,0 +1,62 @@ +import { useEffect } from 'react'; +import { Spinner } from '@openedx/paragon'; +import { useQueryClient } from '@tanstack/react-query'; + +import { useBlockState, lifecycleQueryKeys } from '../data/apiHooks'; +import { LifecycleBadge } from './LifecycleBadge'; +import { LifecycleActionButtons } from './LifecycleActionButtons'; +import { BlockCommentsPanel } from './BlockCommentsPanel'; + +interface Props { + usageKey: string; + title?: string; + hasChanges?: boolean; + /** Optional callback forwarded to LifecycleActionButtons — lets the host page react to a publish. */ + onPublishSuccess?: () => void; +} + +export const LifecycleSection = ({ + usageKey, title = 'Review Status', hasChanges, onPublishSuccess, +}: Props) => { + const queryClient = useQueryClient(); + const { data: blockState, isLoading, error } = useBlockState(usageKey); + + // When the unit gains unpublished changes (e.g. a new component was added), + // the backend signals have already transitioned the block PUBLISHED → DRAFT. + // Invalidate the cached block state so the sidebar reflects the new state + // without requiring a page reload. + useEffect(() => { + if (hasChanges) { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.blockState(usageKey) }); + } + }, [hasChanges, usageKey]); + + const effectiveState = (!hasChanges && blockState?.state === 'draft') ? 'published' : blockState?.state; + + return ( +
+
{title}
+ {isLoading && } + {!isLoading && error && ( +

+ {/* Non-404 means a real backend error; 404 means block not yet in review workflow */} + {(error as any)?.response?.status === 404 + ? 'Not in review workflow' + : 'Could not load review status'} +

+ )} + {!isLoading && blockState && effectiveState && ( + <> + + + + + )} +
+ ); +}; diff --git a/src/course-lifecycle/data/api.mock.ts b/src/course-lifecycle/data/api.mock.ts new file mode 100644 index 0000000000..28cf6a328a --- /dev/null +++ b/src/course-lifecycle/data/api.mock.ts @@ -0,0 +1,49 @@ +import type { BlockReviewComment, BlockReviewState, CourseAggregateState } from './types'; + +export const mockBlockReviewState = (overrides: Partial = {}): BlockReviewState => ({ + usageKey: 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1', + courseId: 'course-v1:TestOrg+TestCourse+2025_T1', + state: 'draft', + isPublishable: false, + submittedBy: null, + submittedAt: null, + approvedBy: null, + approvedAt: null, + publishedAt: null, + canSubmit: true, + canApprove: false, + canRequestChanges: false, + canPublish: false, + ...overrides, +}); + +export const mockCourseAggregateState = (overrides: Partial = {}): CourseAggregateState => ({ + courseId: 'course-v1:TestOrg+TestCourse+2025_T1', + aggregateState: 'draft', + blockCounts: { + draft: 3, + for_review: 1, + approved: 1, + published: 0, + }, + isFullyPublishable: false, + canSubmit: true, + canApprove: false, + canRequestChanges: false, + canPublish: false, + ...overrides, +}); + +export const mockBlockReviewComment = (overrides: Partial = {}): BlockReviewComment => ({ + id: 1, + usageKey: 'block-v1:TestOrg+TestCourse+2025_T1+type@vertical+block@unit1', + courseId: 'course-v1:TestOrg+TestCourse+2025_T1', + author: 'abc123', + comment: 'Needs revision', + resolved: false, + resolvedBy: null, + resolvedAt: null, + created: '2025-04-09T12:00:00.000Z', + modified: '2025-04-09T12:00:00.000Z', + ...overrides, +}); diff --git a/src/course-lifecycle/data/api.ts b/src/course-lifecycle/data/api.ts new file mode 100644 index 0000000000..f309e2836e --- /dev/null +++ b/src/course-lifecycle/data/api.ts @@ -0,0 +1,130 @@ +import { getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { camelCaseObject } from '@edx/frontend-platform/utils'; + +import type { BlockReviewComment, BlockReviewState, CourseAggregateState } from './types'; + +const getLifecycleBaseUrl = () => `${getConfig().STUDIO_BASE_URL}/fbr/api/lifecycle`; + +export async function getBlockState(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/state`, + ); + return camelCaseObject(data) as BlockReviewState; +} + +export async function getCourseAggregateState(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/state`, + ); + return camelCaseObject(data) as CourseAggregateState; +} + +export async function getBulkCourseAggregateStates(courseIds: string[]): Promise> { + const { data } = await getAuthenticatedHttpClient().get( + `${getLifecycleBaseUrl()}/v1/courses/states`, + { params: { course_ids: courseIds.join(',') } }, + ); + return data as Record; +} + +export async function submitForReview(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/submit`, + ); + return camelCaseObject(data) as BlockReviewState; +} + +export async function approveBlock(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/approve`, + ); + return camelCaseObject(data) as BlockReviewState; +} + +export async function requestChanges(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/request-changes`, + ); + return camelCaseObject(data) as BlockReviewState; +} + +export async function publishBlock(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/publish`, + ); + return camelCaseObject(data) as BlockReviewState; +} + +export async function submitCourseForReview(courseId: string): Promise<{ transitioned: number }> { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/submit`, + ); + return data; +} + +export async function approveCourse(courseId: string): Promise<{ transitioned: number }> { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/approve`, + ); + return data; +} + +export async function requestCourseChanges(courseId: string): Promise<{ transitioned: number }> { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/request-changes`, + ); + return data; +} + +export async function publishCourse(courseId: string): Promise<{ status: string }> { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/publish`, + ); + return data; +} + +export async function getCourseComments(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient().get( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/comments`, + ); + return camelCaseObject(data) as BlockReviewComment[]; +} + +export async function createCourseComment(courseId: string, comment: string): Promise { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/courses/${encodeURIComponent(courseId)}/comments`, + { comment }, + ); + return camelCaseObject(data) as BlockReviewComment; +} + +export async function getBlockComments(usageKey: string): Promise { + const { data } = await getAuthenticatedHttpClient().get( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/comments`, + ); + return camelCaseObject(data) as BlockReviewComment[]; +} + +export async function createComment(usageKey: string, comment: string): Promise { + const { data } = await getAuthenticatedHttpClient().post( + `${getLifecycleBaseUrl()}/v1/blocks/${encodeURIComponent(usageKey)}/comments`, + { comment }, + ); + return camelCaseObject(data) as BlockReviewComment; +} + +export async function resolveComment(commentId: number): Promise { + // Backend uses PATCH /v1/comments/{id} with { resolved: true } to resolve a comment + const { data } = await getAuthenticatedHttpClient().patch( + `${getLifecycleBaseUrl()}/v1/comments/${commentId}`, + { resolved: true }, + ); + return camelCaseObject(data) as BlockReviewComment; +} + +export async function deleteComment(commentId: number): Promise { + await getAuthenticatedHttpClient().delete( + `${getLifecycleBaseUrl()}/v1/comments/${commentId}`, + ); +} diff --git a/src/course-lifecycle/data/apiHooks.ts b/src/course-lifecycle/data/apiHooks.ts new file mode 100644 index 0000000000..21903c0b83 --- /dev/null +++ b/src/course-lifecycle/data/apiHooks.ts @@ -0,0 +1,195 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; + +import { courseOutlineQueryKeys } from '@src/course-outline/data/apiHooks'; +import { getCourseKey } from '@src/generic/key-utils'; +import { + approveCourse, + approveBlock, + createComment, + createCourseComment, + deleteComment, + getBlockComments, + getBlockState, + getBulkCourseAggregateStates, + getCourseAggregateState, + getCourseComments, + publishBlock, + publishCourse, + requestChanges, + requestCourseChanges, + resolveComment, + submitCourseForReview, + submitForReview, +} from './api'; + +export const lifecycleQueryKeys = { + blockState: (usageKey: string) => ['lifecycle', 'block', usageKey, 'state'], + blockComments: (usageKey: string) => ['lifecycle', 'block', usageKey, 'comments'], + courseState: (courseId: string) => ['lifecycle', 'course', courseId, 'state'], + courseComments: (courseId: string) => ['lifecycle', 'course', courseId, 'comments'], + bulkCourseStates: (courseIds: string[]) => ['lifecycle', 'courses', 'bulk', ...courseIds], +}; + +export const useBulkCourseAggregateStates = (courseIds: string[]) => useQuery({ + queryKey: lifecycleQueryKeys.bulkCourseStates(courseIds), + queryFn: () => getBulkCourseAggregateStates(courseIds), + enabled: courseIds.length > 0, +}); + +export const useCourseAggregateState = (courseId: string, options?: { enabled?: boolean }) => useQuery({ + queryKey: lifecycleQueryKeys.courseState(courseId), + queryFn: () => getCourseAggregateState(courseId), + enabled: options?.enabled ?? true, + retry: (failureCount, error: any) => { + if (error?.response?.status === 404) { return false; } + return failureCount < 3; + }, +}); + +export const useSubmitCourseForReview = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => submitCourseForReview(courseId), + onSuccess: () => { + // Invalidate all lifecycle queries so every block badge re-fetches its state. + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + }, + }); +}; + +export const useApproveCourse = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => approveCourse(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + }, + }); +}; + +export const useRequestCourseChanges = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => requestCourseChanges(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + }, + }); +}; + +export const usePublishCourse = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => publishCourse(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.contentLibrary(courseId) }); + }, + }); +}; + +export const useBlockState = (usageKey: string) => useQuery({ + queryKey: lifecycleQueryKeys.blockState(usageKey), + queryFn: () => getBlockState(usageKey), + // Treat 404 as "unmanaged block" — return null instead of throwing + retry: (failureCount, error: any) => { + if (error?.response?.status === 404) { + return false; + } + return failureCount < 3; + }, +}); + +export const useBlockComments = (usageKey: string) => useQuery({ + queryKey: lifecycleQueryKeys.blockComments(usageKey), + queryFn: () => getBlockComments(usageKey), +}); + +export const useCourseComments = (courseId: string) => useQuery({ + queryKey: lifecycleQueryKeys.courseComments(courseId), + queryFn: () => getCourseComments(courseId), +}); + +export const useSubmitForReview = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => submitForReview(usageKey), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + }, + }); +}; + +export const useApproveBlock = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => approveBlock(usageKey), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + }, + }); +}; + +export const useRequestChanges = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => requestChanges(usageKey), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + }, + }); +}; + +export const usePublishBlock = (usageKey: string, options?: { onSuccess?: () => void }) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => publishBlock(usageKey), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); + // Refresh all outline blocks for this course so status badges update without a page reload. + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.contentLibrary(getCourseKey(usageKey)) }); + // Allows the unit page to re-fetch its Redux state after a lifecycle publish (see UnitInfoSidebar). + options?.onSuccess?.(); + }, + }); +}; + +export const useCreateComment = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (comment: string) => createComment(usageKey, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.blockComments(usageKey) }); + }, + }); +}; + +export const useCreateCourseComment = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (comment: string) => createCourseComment(courseId, comment), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseComments(courseId) }); + }, + }); +}; + +export const useResolveComment = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (commentId: number) => resolveComment(commentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.blockComments(usageKey) }); + }, + }); +}; + +export const useDeleteComment = (usageKey: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (commentId: number) => deleteComment(commentId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.blockComments(usageKey) }); + }, + }); +}; diff --git a/src/course-lifecycle/data/types.ts b/src/course-lifecycle/data/types.ts new file mode 100644 index 0000000000..6c4d3b7f5b --- /dev/null +++ b/src/course-lifecycle/data/types.ts @@ -0,0 +1,41 @@ +export type LifecycleState = 'draft' | 'for_review' | 'approved' | 'published'; + +export interface BlockReviewState { + usageKey: string; + courseId: string; + state: LifecycleState; + isPublishable: boolean; + submittedBy: string | null; + submittedAt: string | null; + approvedBy: string | null; + approvedAt: string | null; + publishedAt: string | null; + canSubmit: boolean; + canApprove: boolean; + canRequestChanges: boolean; + canPublish: boolean; +} + +export interface CourseAggregateState { + courseId: string; + aggregateState: LifecycleState | null; + blockCounts: Partial>; + isFullyPublishable: boolean; + canSubmit: boolean; + canApprove: boolean; + canRequestChanges: boolean; + canPublish: boolean; +} + +export interface BlockReviewComment { + id: number; + usageKey: string; + courseId: string; + author: string; + comment: string; + resolved: boolean; + resolvedBy: string | null; + resolvedAt: string | null; + created: string; + modified: string; +} diff --git a/src/course-lifecycle/index.ts b/src/course-lifecycle/index.ts new file mode 100644 index 0000000000..4c687e8cda --- /dev/null +++ b/src/course-lifecycle/index.ts @@ -0,0 +1,21 @@ +export { LifecycleSection } from './components/LifecycleSection'; +export { LifecycleBadge } from './components/LifecycleBadge'; +export { LifecycleActionButtons } from './components/LifecycleActionButtons'; +export { LifecycleModal } from './components/LifecycleModal'; +export { BlockCommentsPanel } from './components/BlockCommentsPanel'; +export { CourseCommentsPanel } from './components/CourseCommentsPanel'; +export { CourseLifecycleSection } from './components/CourseLifecycleSection'; +export { + useBlockState, + useBlockComments, + useCourseAggregateState, + useCourseComments, + useSubmitCourseForReview, + useApproveCourse, + useRequestCourseChanges, + usePublishCourse, + lifecycleQueryKeys, +} from './data/apiHooks'; +export type { + BlockReviewState, BlockReviewComment, LifecycleState, CourseAggregateState, +} from './data/types'; diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index cd914c5237..9da884ea96 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -25,6 +25,8 @@ import TagCount from '@src/generic/tag-count'; import { useEscapeClick } from '@src/hooks'; import { XBlockActions } from '@src/data/types'; import { RequestStatus, RequestStatusType } from '@src/data/constants'; +import { LifecycleBadge } from '@src/course-lifecycle/components/LifecycleBadge'; +import type { LifecycleState } from '@src/course-lifecycle/data/types'; import { ITEM_BADGE_STATUS } from '../constants'; import { scrollToElement } from '../utils'; import CardStatus from './CardStatus'; @@ -70,6 +72,9 @@ interface CardHeaderProps { onClickSync?: () => void; readyToSync?: boolean; savingStatus?: RequestStatusType; + canPublish?: boolean; + lifecycleState?: LifecycleState; + onClickLifecycle?: () => void; } const CardHeader = ({ @@ -104,6 +109,9 @@ const CardHeader = ({ onClickSync, readyToSync, savingStatus, + canPublish, + lifecycleState, + onClickLifecycle, }: CardHeaderProps) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -119,6 +127,11 @@ const CardHeader = ({ const isDisabledPublish = (status === ITEM_BADGE_STATUS.live || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; + // Show the Publish menu item by default; hide only when the lifecycle system explicitly + // denies permission (false = in lifecycle but not approved). + // undefined (not in lifecycle) → show; false (not approved) → hide; true (approved) → show. + const showPublishItem = canPublish !== false; + const { data: contentTagCount } = useContentTagsCount(cardId); const isSaving = savingStatus === RequestStatus.IN_PROGRESS; @@ -192,10 +205,21 @@ const CardHeader = ({ /> )} -
+
{(isVertical || isSequential) && ( )} + {lifecycleState && ( + // eslint-disable-next-line jsx-a11y/interactive-supports-focus + { if (e.key === 'Enter') { onClickLifecycle?.(); } }} + > + + + )} { getConfig().ENABLE_TAGGING_TAXONOMY_PAGES === 'true' && !!contentTagCount && ( )} @@ -231,13 +255,15 @@ const CardHeader = ({ {intl.formatMessage(messages.menuProctoringLinkText)} )} - - {intl.formatMessage(messages.menuPublish)} - + {showPublishItem && ( + + {intl.formatMessage(messages.menuPublish)} + + )} { @@ -31,6 +32,11 @@ const OutlineSideBar = ({ courseId }) => { className="outline-sidebar mt-4" data-testid="outline-sidebar" > +
+

Course Review Status

+ +
+
{sidebarMessages.map(({ title, descriptions, link }, index) => { const isLastSection = index === sidebarMessages.length - 1; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 02c1d5c644..41b8c24fdc 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -28,6 +28,8 @@ import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; +import { useBlockState } from '@src/course-lifecycle/data/apiHooks'; +import { LifecycleModal } from '@src/course-lifecycle/components/LifecycleModal'; import messages from './messages'; interface SectionCardProps { @@ -116,6 +118,7 @@ const SectionCard = ({ const [isExpanded, setIsExpanded] = useState(containsSearchResult() || isSectionsExpanded); const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); + const [isLifecycleModalOpen, openLifecycleModal, closeLifecycleModal] = useToggle(false); const namePrefix = 'section'; useEffect(() => { @@ -135,6 +138,24 @@ const SectionCard = ({ upstreamInfo, } = section; + const { data: blockLifecycleState } = useBlockState(id); + + // Reactively refresh the Redux outline state when this block is published via the lifecycle + // system. useEffect is used instead of a mutation callback because TanStack Query captures + // useMutation options at hook-init time, making callbacks unreliable after re-renders. + const prevLifecycleStateRef = useRef(undefined); + useEffect(() => { + const curr = blockLifecycleState?.state; + if ( + prevLifecycleStateRef.current !== undefined + && prevLifecycleStateRef.current !== 'published' + && curr === 'published' + ) { + dispatch(fetchCourseSectionQuery([id])); + } + prevLifecycleStateRef.current = curr; + }, [blockLifecycleState?.state]); + const blockSyncData = useMemo(() => { if (!upstreamInfo?.readyToSync) { return undefined; @@ -309,6 +330,9 @@ const SectionCard = ({ namePrefix={namePrefix} actions={actions} readyToSync={upstreamInfo?.readyToSync} + canPublish={blockLifecycleState?.canPublish} + lifecycleState={blockLifecycleState?.state} + onClickLifecycle={openLifecycleModal} /> )}
@@ -372,6 +396,15 @@ const SectionCard = ({ postChange={handleOnPostChangeSync} /> )} + {blockLifecycleState && ( + + )} ); }; diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 389338519c..7f515669fd 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -29,6 +29,8 @@ import OutlineAddChildButtons from '@src/course-outline/OutlineAddChildButtons'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import type { XBlock } from '@src/data/types'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; +import { useBlockState } from '@src/course-lifecycle/data/apiHooks'; +import { LifecycleModal } from '@src/course-lifecycle/components/LifecycleModal'; import messages from './messages'; interface SubsectionCardProps { @@ -93,6 +95,7 @@ const SubsectionCard = ({ const isScrolledToElement = locatorId === subsection.id; const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); + const [isLifecycleModalOpen, openLifecycleModal, closeLifecycleModal] = useToggle(false); const namePrefix = 'subsection'; const { sharedClipboardData, showPasteUnit } = useClipboard(); const [ @@ -117,6 +120,21 @@ const SubsectionCard = ({ upstreamInfo, } = subsection; + const { data: blockLifecycleState } = useBlockState(id); + + const prevLifecycleStateRef = useRef(undefined); + useEffect(() => { + const curr = blockLifecycleState?.state; + if ( + prevLifecycleStateRef.current !== undefined + && prevLifecycleStateRef.current !== 'published' + && curr === 'published' + ) { + dispatch(fetchCourseSectionQuery([section.id])); + } + prevLifecycleStateRef.current = curr; + }, [blockLifecycleState?.state]); + const blockSyncData = useMemo(() => { if (!upstreamInfo?.readyToSync) { return undefined; @@ -314,6 +332,9 @@ const SubsectionCard = ({ isSequential extraActionsComponent={extraActionsComponent} readyToSync={upstreamInfo?.readyToSync} + canPublish={blockLifecycleState?.canPublish} + lifecycleState={blockLifecycleState?.state} + onClickLifecycle={openLifecycleModal} />
)} + {blockLifecycleState && ( + + )} ); }; diff --git a/src/course-outline/unit-card/UnitCard.tsx b/src/course-outline/unit-card/UnitCard.tsx index 8029f603cc..22e334db47 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -24,6 +24,8 @@ import { UpstreamInfoIcon } from '@src/generic/upstream-info-icon'; import { PreviewLibraryXBlockChanges } from '@src/course-unit/preview-changes'; import { invalidateLinksQuery } from '@src/course-libraries/data/apiHooks'; import type { XBlock } from '@src/data/types'; +import { useBlockState } from '@src/course-lifecycle/data/apiHooks'; +import { LifecycleModal } from '@src/course-lifecycle/components/LifecycleModal'; interface UnitCardProps { unit: XBlock; @@ -74,6 +76,7 @@ const UnitCard = ({ const isScrolledToElement = locatorId === unit.id; const [isFormOpen, openForm, closeForm] = useToggle(false); const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); + const [isLifecycleModalOpen, openLifecycleModal, closeLifecycleModal] = useToggle(false); const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); @@ -94,6 +97,24 @@ const UnitCard = ({ upstreamInfo, } = unit; + // Fetch lifecycle state to determine publish permission for this unit. + // canPublish encodes: state===APPROVED AND is_publishable. + // React Query caches per usage key; undefined when block is not in lifecycle system (404). + const { data: blockLifecycleState } = useBlockState(id); + + const prevLifecycleStateRef = useRef(undefined); + useEffect(() => { + const curr = blockLifecycleState?.state; + if ( + prevLifecycleStateRef.current !== undefined + && prevLifecycleStateRef.current !== 'published' + && curr === 'published' + ) { + dispatch(fetchCourseSectionQuery([section.id])); + } + prevLifecycleStateRef.current = curr; + }, [blockLifecycleState?.state]); + const blockSyncData = useMemo(() => { if (!upstreamInfo?.readyToSync) { return undefined; @@ -259,6 +280,9 @@ const UnitCard = ({ parentInfo={parentInfo} extraActionsComponent={extraActionsComponent} readyToSync={upstreamInfo?.readyToSync} + canPublish={blockLifecycleState?.canPublish} + lifecycleState={blockLifecycleState?.state} + onClickLifecycle={openLifecycleModal} />
)} + {blockLifecycleState && ( + + )} ); }; diff --git a/src/course-unit/sidebar/PublishControls.tsx b/src/course-unit/sidebar/PublishControls.tsx index 2b6372548c..b5c826eb7a 100644 --- a/src/course-unit/sidebar/PublishControls.tsx +++ b/src/course-unit/sidebar/PublishControls.tsx @@ -10,6 +10,7 @@ import { PUBLISH_TYPES, messageTypes } from '../constants'; import { getCourseUnitData } from '../data/selectors'; import messages from './messages'; import ModalNotification from '../../generic/modal-notification'; +import { LifecycleSection, useBlockState } from '../../course-lifecycle'; interface PublishControlsProps { blockId?: string, @@ -17,6 +18,8 @@ interface PublishControlsProps { const PublishControls = ({ blockId }: PublishControlsProps) => { const unitData = useSelector(getCourseUnitData); + const { hasChanges } = unitData || {}; + const { data: blockLifecycleState } = useBlockState(blockId ?? ''); const { title, locationId, @@ -69,6 +72,7 @@ const PublishControls = ({ blockId }: PublishControlsProps) => { openVisibleModal={openVisibleModal} handlePublishing={handleCourseUnitPublish} visibleToStaffOnly={visibleToStaffOnly} + hidePublishButton={!!blockLifecycleState} /> { message={intl.formatMessage(messages.modalMakeVisibilityDescription)} icon={InfoOutlineIcon} /> + {blockId && ( +
+ +
+ )} ); }; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx index 8bedc41c8b..3557197756 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx @@ -10,11 +10,15 @@ import messages from '../../messages'; interface ActionButtonsProps { openDiscardModal: () => void, handlePublishing: () => void, + hideCopyButton?: boolean, + hidePublishButton?: boolean, } const ActionButtons = ({ openDiscardModal, handlePublishing, + hideCopyButton = false, + hidePublishButton = false, }: ActionButtonsProps) => { const intl = useIntl(); const { @@ -28,7 +32,7 @@ const ActionButtons = ({ return ( <> - {(!published || hasChanges) && ( + {!hidePublishButton && (!published || hasChanges) && (