From 6bdf49e9698e1db6b53fd34f55c64b62eccde0b2 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Thu, 2 Apr 2026 13:37:09 +0500 Subject: [PATCH 1/5] feat: add course lifecycle management UI --- .../components/BlockCommentsPanel.tsx | 110 ++++++++ .../components/CourseCommentsPanel.tsx | 120 +++++++++ .../components/CourseLifecycleSection.tsx | 115 ++++++++ .../components/LifecycleActionButtons.tsx | 91 +++++++ .../components/LifecycleBadge.tsx | 23 ++ .../components/LifecycleSection.tsx | 57 ++++ src/course-lifecycle/data/api.ts | 122 +++++++++ src/course-lifecycle/data/apiHooks.ts | 182 +++++++++++++ src/course-lifecycle/data/types.ts | 41 +++ src/course-lifecycle/index.ts | 18 ++ src/course-outline/card-header/CardHeader.tsx | 22 +- .../info-sidebar/CourseInfoSidebar.tsx | 63 +++++ .../info-sidebar/SectionInfoSidebar.tsx | 55 ++++ .../info-sidebar/SubsectionInfoSidebar.tsx | 55 ++++ .../info-sidebar/UnitInfoSidebar.tsx | 93 +++++++ .../section-card/SectionCard.tsx | 4 + .../subsection-card/SubsectionCard.tsx | 4 + src/course-outline/unit-card/UnitCard.tsx | 7 + .../sidebar-footer/ActionButtons.tsx | 6 +- .../components/sidebar-footer/index.tsx | 6 + .../unit-info/PublishControls.tsx | 177 +++++++++++++ .../unit-info/UnitInfoSidebar.tsx | 248 ++++++++++++++++++ 22 files changed, 1611 insertions(+), 8 deletions(-) create mode 100644 src/course-lifecycle/components/BlockCommentsPanel.tsx create mode 100644 src/course-lifecycle/components/CourseCommentsPanel.tsx create mode 100644 src/course-lifecycle/components/CourseLifecycleSection.tsx create mode 100644 src/course-lifecycle/components/LifecycleActionButtons.tsx create mode 100644 src/course-lifecycle/components/LifecycleBadge.tsx create mode 100644 src/course-lifecycle/components/LifecycleSection.tsx create mode 100644 src/course-lifecycle/data/api.ts create mode 100644 src/course-lifecycle/data/apiHooks.ts create mode 100644 src/course-lifecycle/data/types.ts create mode 100644 src/course-lifecycle/index.ts create mode 100644 src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx create mode 100644 src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx create mode 100644 src/course-unit/unit-sidebar/unit-info/PublishControls.tsx create mode 100644 src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx diff --git a/src/course-lifecycle/components/BlockCommentsPanel.tsx b/src/course-lifecycle/components/BlockCommentsPanel.tsx new file mode 100644 index 0000000000..78bc8ac528 --- /dev/null +++ b/src/course-lifecycle/components/BlockCommentsPanel.tsx @@ -0,0 +1,110 @@ +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.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.tsx b/src/course-lifecycle/components/CourseLifecycleSection.tsx new file mode 100644 index 0000000000..24e5d5898b --- /dev/null +++ b/src/course-lifecycle/components/CourseLifecycleSection.tsx @@ -0,0 +1,115 @@ +import { Badge, Button, Spinner } from '@openedx/paragon'; + +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 { data, isLoading, error } = useCourseAggregateState(courseId); + const submitMutation = useSubmitCourseForReview(courseId); + const approveMutation = useApproveCourse(courseId); + const requestChangesMutation = useRequestCourseChanges(courseId); + const publishMutation = usePublishCourse(courseId); + + return ( +
+ {isLoading && } + {!isLoading && (error || !data) && ( +

Not tracked

+ )} + {!isLoading && data && ( + <> + {data.aggregateState ? ( + + ) : ( +

Not tracked

+ )} +
+ {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) && ( +
+ {data.canSubmit && ( + + )} + {data.canApprove && ( + + )} + {data.canRequestChanges && ( + + )} + {data.canPublish && ( + + )} +
+ )} + + + )} +
+ ); +}; 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.tsx b/src/course-lifecycle/components/LifecycleBadge.tsx new file mode 100644 index 0000000000..0b38123b3f --- /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} + + ); +}; \ No newline at end of file diff --git a/src/course-lifecycle/components/LifecycleSection.tsx b/src/course-lifecycle/components/LifecycleSection.tsx new file mode 100644 index 0000000000..b0349009be --- /dev/null +++ b/src/course-lifecycle/components/LifecycleSection.tsx @@ -0,0 +1,57 @@ +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.ts b/src/course-lifecycle/data/api.ts new file mode 100644 index 0000000000..c5fe3fc957 --- /dev/null +++ b/src/course-lifecycle/data/api.ts @@ -0,0 +1,122 @@ +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 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..fcd060b1ae --- /dev/null +++ b/src/course-lifecycle/data/apiHooks.ts @@ -0,0 +1,182 @@ +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, + 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'], +}; + +export const useCourseAggregateState = (courseId: string) => useQuery({ + queryKey: lifecycleQueryKeys.courseState(courseId), + queryFn: () => getCourseAggregateState(courseId), +}); + +export const useSubmitCourseForReview = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => submitCourseForReview(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + }, + }); +}; + +export const useApproveCourse = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => approveCourse(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + }, + }); +}; + +export const useRequestCourseChanges = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => requestCourseChanges(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + }, + }); +}; + +export const usePublishCourse = (courseId: string) => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => publishCourse(courseId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + // Refresh all outline blocks so their published/draft badges update without a page reload. + queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.course(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.course(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..97f288535c --- /dev/null +++ b/src/course-lifecycle/index.ts @@ -0,0 +1,18 @@ +export { LifecycleSection } from './components/LifecycleSection'; +export { LifecycleBadge } from './components/LifecycleBadge'; +export { LifecycleActionButtons } from './components/LifecycleActionButtons'; +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..735a0f1e23 100644 --- a/src/course-outline/card-header/CardHeader.tsx +++ b/src/course-outline/card-header/CardHeader.tsx @@ -70,6 +70,7 @@ interface CardHeaderProps { onClickSync?: () => void; readyToSync?: boolean; savingStatus?: RequestStatusType; + canPublish?: boolean; } const CardHeader = ({ @@ -104,6 +105,7 @@ const CardHeader = ({ onClickSync, readyToSync, savingStatus, + canPublish, }: CardHeaderProps) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -119,6 +121,10 @@ const CardHeader = ({ const isDisabledPublish = (status === ITEM_BADGE_STATUS.live || status === ITEM_BADGE_STATUS.publishedNotLive) && !hasChanges; + // Show the Publish menu item only when the lifecycle system explicitly grants permission. + // undefined (not in lifecycle or not yet fetched) → hidden; false (not approved) → hidden. + const showPublishItem = canPublish === true; + const { data: contentTagCount } = useContentTagsCount(cardId); const isSaving = savingStatus === RequestStatus.IN_PROGRESS; @@ -231,13 +237,15 @@ const CardHeader = ({ {intl.formatMessage(messages.menuProctoringLinkText)} )} - - {intl.formatMessage(messages.menuPublish)} - + {showPublishItem && ( + + {intl.formatMessage(messages.menuPublish)} + + )} { + const intl = useIntl(); + const { courseId } = useCourseAuthoringContext(); + const { data: courseDetails } = useCourseDetails(courseId); + + const { data: componentData } = useGetBlockTypes( + [`context_key = "${courseId}"`], + ); + + const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); + + return ( +
+ + + + + + + {componentData && } + + + + + + +
+ ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx new file mode 100644 index 0000000000..dfba79f364 --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs } from '@openedx/paragon'; + +import { getItemIcon } from '@src/generic/block-type-utils'; + +import { SidebarTitle } from '@src/generic/sidebar'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { LifecycleSection } from '@src/course-lifecycle'; +import { InfoSection } from './InfoSection'; +import messages from '../messages'; + +interface Props { + sectionId: string; +} + +export const SectionSidebar = ({ sectionId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'info' | 'settings'>('info'); + const { data: sectionData, isLoading } = useCourseItemData(sectionId); + const { clearSelection } = useOutlineSidebarContext(); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + +
Settings
+
+
+ + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx new file mode 100644 index 0000000000..f5fa04e2ce --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Tab, Tabs } from '@openedx/paragon'; + +import { getItemIcon } from '@src/generic/block-type-utils'; + +import { SidebarTitle } from '@src/generic/sidebar'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; +import { LifecycleSection } from '@src/course-lifecycle'; +import { InfoSection } from './InfoSection'; +import messages from '../messages'; + +interface Props { + subsectionId: string; +} + +export const SubsectionSidebar = ({ subsectionId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'info' | 'settings'>('info'); + const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); + const { clearSelection } = useOutlineSidebarContext(); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + +
Settings
+
+
+ + ); +}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx new file mode 100644 index 0000000000..bf75fa60bb --- /dev/null +++ b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx @@ -0,0 +1,93 @@ +import { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Button, Tab, Tabs, +} from '@openedx/paragon'; +import { + OpenInFull, +} from '@openedx/paragon/icons'; + +import { getItemIcon } from '@src/generic/block-type-utils'; + +import { SidebarTitle } from '@src/generic/sidebar'; + +import { useCourseItemData } from '@src/course-outline/data/apiHooks'; +import Loading from '@src/generic/Loading'; +import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; +import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; +import { Link } from 'react-router-dom'; +import { LifecycleSection } from '@src/course-lifecycle'; +import { useOutlineSidebarContext } from '../OutlineSidebarContext'; +import messages from '../messages'; +import { InfoSection } from './InfoSection'; + +interface Props { + unitId: string; +} + +export const UnitSidebar = ({ unitId }: Props) => { + const intl = useIntl(); + const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); + const { data: unitData, isLoading } = useCourseItemData(unitId); + const { clearSelection } = useOutlineSidebarContext(); + const { getUnitUrl, courseId } = useCourseAuthoringContext(); + + if (isLoading) { + return ; + } + + return ( + <> + + + + + + + {}, handleDuplicate: () => {}, handleUnlink: () => {} }} + courseVerticalChildren={[]} + handleConfigureSubmit={() => {}} + readonly + /> + + + + + + +
Settings
+
+
+ + ); +}; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 02c1d5c644..2cd5a645bd 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -28,6 +28,7 @@ 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 messages from './messages'; interface SectionCardProps { @@ -135,6 +136,8 @@ const SectionCard = ({ upstreamInfo, } = section; + const { data: blockLifecycleState } = useBlockState(id); + const blockSyncData = useMemo(() => { if (!upstreamInfo?.readyToSync) { return undefined; @@ -309,6 +312,7 @@ const SectionCard = ({ namePrefix={namePrefix} actions={actions} readyToSync={upstreamInfo?.readyToSync} + canPublish={blockLifecycleState?.canPublish} /> )}
diff --git a/src/course-outline/subsection-card/SubsectionCard.tsx b/src/course-outline/subsection-card/SubsectionCard.tsx index 389338519c..46fd2adb64 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -29,6 +29,7 @@ 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 messages from './messages'; interface SubsectionCardProps { @@ -117,6 +118,8 @@ const SubsectionCard = ({ upstreamInfo, } = subsection; + const { data: blockLifecycleState } = useBlockState(id); + const blockSyncData = useMemo(() => { if (!upstreamInfo?.readyToSync) { return undefined; @@ -314,6 +317,7 @@ const SubsectionCard = ({ isSequential extraActionsComponent={extraActionsComponent} readyToSync={upstreamInfo?.readyToSync} + canPublish={blockLifecycleState?.canPublish} />
{ if (!upstreamInfo?.readyToSync) { return undefined; @@ -259,6 +265,7 @@ const UnitCard = ({ parentInfo={parentInfo} extraActionsComponent={extraActionsComponent} readyToSync={upstreamInfo?.readyToSync} + canPublish={blockLifecycleState?.canPublish} />
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) && ( + + + + + + {({ + values, setFieldValue, dirty, + }) => ( +
+ + {dirty && ( + + )} + + )} +
+
+ + handleUpdate(visibleToStaffOnly, null, e.target.checked)} + /> + + + ); +}; + +/** + * Component that renders the tabs of the info sidebar for units. + */ +export const UnitInfoSidebar = () => { + const intl = useIntl(); + const currentItemData = useSelector(getCourseUnitData); + const { + currentTabKey, + setCurrentTabKey, + } = useUnitSidebarContext(); + + useEffect(() => { + // Set default Tab key + setCurrentTabKey('details'); + }, []); + + return ( +
+ + + +
+ +
+
+ +
+ +
+
+
+
+ ); +}; From 070938b4efd05b38917124a1995615569129664b Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Tue, 7 Apr 2026 18:29:10 +0500 Subject: [PATCH 2/5] feat: switch lifecycle feature to modal approach, remove backported sidebar files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create LifecycleModal: wraps LifecycleBadge + LifecycleActionButtons + BlockCommentsPanel in a Paragon ModalDialog (no sidebar dependency) - Update CardHeader: add lifecycleState + onClickLifecycle props; render clickable LifecycleBadge next to status badges in each card header - Update SectionCard / SubsectionCard / UnitCard: open LifecycleModal on badge click; modal is only rendered when blockLifecycleState is available - Add LifecycleSection to old unit-page PublishControls sidebar (no new deps) - Add CourseLifecycleSection to OutlineSidebar for course-level review state - Delete backported info-sidebar files (SectionInfoSidebar, SubsectionInfoSidebar, UnitInfoSidebar, CourseInfoSidebar) and backported unit-sidebar files — the modal approach has zero dependency on the new sidebar system fix: improve course review status sidebar styling and unit section margins - CourseLifecycleSection: add labeled sub-sections (Current Status, Block Breakdown, Actions) with uppercase muted headings for better scannability - OutlineSidebar: rename heading to 'Course Review Status' - PublishControls: wrap LifecycleSection in px-3 to match sidebar side margins fix: hide standard publish button when lifecycle system is active on unit page When a unit is enrolled in the review workflow (useBlockState returns data), pass hidePublishButton=true to SidebarFooter so the native Publish button is suppressed — publishing is handled exclusively through LifecycleSection. fix: invalidate all lifecycle queries on course-level mutations Course-level mutations were only invalidating lifecycleQueryKeys.courseState, leaving every block's cached state stale. Switching to queryKey: ['lifecycle'] matches what block-level mutations already do, so all block badges in card headers re-fetch immediately after Submit All / Approve All / Publish Course. fix: refresh Redux outline state after lifecycle publish Lifecycle publish mutations only invalidated React Query caches, but the course outline's section/subsection/unit published state (yellow draft badges, hasChanges) lives in Redux and was not updated without a reload. - LifecycleModal: accept onPublishSuccess prop and call it alongside onClose - SectionCard / SubsectionCard / UnitCard: pass onPublishSuccess that dispatches fetchCourseSectionQuery([section.id]) to pull fresh Redux state - CourseLifecycleSection: dispatch fetchCourseOutlineIndexQuery after Publish Course to refresh the entire outline tree in Redux - usePublishCourse: accept options.onSuccess callback (mirrors usePublishBlock) fix: use reactive useEffect to refresh Redux outline after lifecycle publish The previous approach of threading an onPublishSuccess callback through LifecycleModal → LifecycleActionButtons → usePublishBlock was unreliable because TanStack Query captures useMutation options at hook-init time, making the callback stale after component re-renders. New approach: watch blockLifecycleState?.state (and data?.aggregateState for course-level) in a useEffect. When a block/course transitions from any non-published state to 'published', dispatch the Redux thunk directly to refresh the outline without depending on any mutation callback chain. - SectionCard / SubsectionCard / UnitCard: useEffect dispatches fetchCourseSectionQuery([section.id]) on state → 'published' transition - CourseLifecycleSection: useEffect dispatches fetchCourseOutlineIndexQuery on aggregateState → 'published' transition - Remove now-redundant onPublishSuccess props from LifecycleModal usages - Revert usePublishCourse options param (no longer needed) --- .../components/CourseLifecycleSection.tsx | 151 ++++++----- .../components/LifecycleModal.tsx | 59 +++++ src/course-lifecycle/data/apiHooks.ts | 10 +- src/course-lifecycle/index.ts | 1 + src/course-outline/card-header/CardHeader.tsx | 19 +- .../outline-sidebar/OutlineSidebar.jsx | 6 + .../info-sidebar/CourseInfoSidebar.tsx | 63 ----- .../info-sidebar/SectionInfoSidebar.tsx | 55 ---- .../info-sidebar/SubsectionInfoSidebar.tsx | 55 ---- .../info-sidebar/UnitInfoSidebar.tsx | 93 ------- .../section-card/SectionCard.tsx | 29 ++ .../subsection-card/SubsectionCard.tsx | 26 ++ src/course-outline/unit-card/UnitCard.tsx | 26 ++ src/course-unit/sidebar/PublishControls.tsx | 9 + .../unit-info/PublishControls.tsx | 177 ------------- .../unit-info/UnitInfoSidebar.tsx | 248 ------------------ 16 files changed, 270 insertions(+), 757 deletions(-) create mode 100644 src/course-lifecycle/components/LifecycleModal.tsx delete mode 100644 src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx delete mode 100644 src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx delete mode 100644 src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx delete mode 100644 src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx delete mode 100644 src/course-unit/unit-sidebar/unit-info/PublishControls.tsx delete mode 100644 src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx diff --git a/src/course-lifecycle/components/CourseLifecycleSection.tsx b/src/course-lifecycle/components/CourseLifecycleSection.tsx index 24e5d5898b..bb56fdda55 100644 --- a/src/course-lifecycle/components/CourseLifecycleSection.tsx +++ b/src/course-lifecycle/components/CourseLifecycleSection.tsx @@ -1,5 +1,8 @@ +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, @@ -27,85 +30,113 @@ interface Props { } 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) && ( -

Not tracked

+

This course is not enrolled in the review workflow.

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

Current Status

{data.aggregateState ? ( ) : (

Not tracked

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

Actions

+
+ {data.canSubmit && ( + + )} + {data.canApprove && ( + + )} + {data.canRequestChanges && ( + + )} + {data.canPublish && ( + + )} +
+ )} diff --git a/src/course-lifecycle/components/LifecycleModal.tsx b/src/course-lifecycle/components/LifecycleModal.tsx new file mode 100644 index 0000000000..47db2c11f6 --- /dev/null +++ b/src/course-lifecycle/components/LifecycleModal.tsx @@ -0,0 +1,59 @@ +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/data/apiHooks.ts b/src/course-lifecycle/data/apiHooks.ts index fcd060b1ae..ac73c81282 100644 --- a/src/course-lifecycle/data/apiHooks.ts +++ b/src/course-lifecycle/data/apiHooks.ts @@ -38,7 +38,8 @@ export const useSubmitCourseForReview = (courseId: string) => { return useMutation({ mutationFn: () => submitCourseForReview(courseId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + // Invalidate all lifecycle queries so every block badge re-fetches its state. + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); }, }); }; @@ -48,7 +49,7 @@ export const useApproveCourse = (courseId: string) => { return useMutation({ mutationFn: () => approveCourse(courseId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); }, }); }; @@ -58,7 +59,7 @@ export const useRequestCourseChanges = (courseId: string) => { return useMutation({ mutationFn: () => requestCourseChanges(courseId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); }, }); }; @@ -68,8 +69,7 @@ export const usePublishCourse = (courseId: string) => { return useMutation({ mutationFn: () => publishCourse(courseId), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: lifecycleQueryKeys.courseState(courseId) }); - // Refresh all outline blocks so their published/draft badges update without a page reload. + queryClient.invalidateQueries({ queryKey: ['lifecycle'] }); queryClient.invalidateQueries({ queryKey: courseOutlineQueryKeys.course(courseId) }); }, }); diff --git a/src/course-lifecycle/index.ts b/src/course-lifecycle/index.ts index 97f288535c..2f35cf6a32 100644 --- a/src/course-lifecycle/index.ts +++ b/src/course-lifecycle/index.ts @@ -1,6 +1,7 @@ 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'; diff --git a/src/course-outline/card-header/CardHeader.tsx b/src/course-outline/card-header/CardHeader.tsx index 735a0f1e23..27148fc5fd 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'; @@ -71,6 +73,8 @@ interface CardHeaderProps { readyToSync?: boolean; savingStatus?: RequestStatusType; canPublish?: boolean; + lifecycleState?: LifecycleState; + onClickLifecycle?: () => void; } const CardHeader = ({ @@ -106,6 +110,8 @@ const CardHeader = ({ readyToSync, savingStatus, canPublish, + lifecycleState, + onClickLifecycle, }: CardHeaderProps) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -198,10 +204,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 && ( )} diff --git a/src/course-outline/outline-sidebar/OutlineSidebar.jsx b/src/course-outline/outline-sidebar/OutlineSidebar.jsx index 6d659d238d..9d9def320f 100644 --- a/src/course-outline/outline-sidebar/OutlineSidebar.jsx +++ b/src/course-outline/outline-sidebar/OutlineSidebar.jsx @@ -5,6 +5,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { HelpSidebar } from '../../generic/help-sidebar'; import { useHelpUrls } from '../../help-urls/hooks'; +import { CourseLifecycleSection } from '../../course-lifecycle'; import { getFormattedSidebarMessages } from './utils'; const OutlineSideBar = ({ courseId }) => { @@ -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/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx deleted file mode 100644 index d15bb75ece..0000000000 --- a/src/course-outline/outline-sidebar/info-sidebar/CourseInfoSidebar.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { useIntl } from '@edx/frontend-platform/i18n'; -import { useToggle } from '@openedx/paragon'; -import { SchoolOutline, Tag } from '@openedx/paragon/icons'; - -import { ContentTagsDrawerSheet, ContentTagsSnippet } from '@src/content-tags-drawer'; -import { ComponentCountSnippet } from '@src/generic/block-type-utils'; -import { useGetBlockTypes } from '@src/search-manager'; -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; - -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; - -import { useCourseDetails } from '@src/course-outline/data/apiHooks'; -import { CourseLifecycleSection } from '@src/course-lifecycle'; -import messages from '../messages'; - -export const CourseInfoSidebar = () => { - const intl = useIntl(); - const { courseId } = useCourseAuthoringContext(); - const { data: courseDetails } = useCourseDetails(courseId); - - const { data: componentData } = useGetBlockTypes( - [`context_key = "${courseId}"`], - ); - - const [isManageTagsDrawerOpen, openManageTagsDrawer, closeManageTagsDrawer] = useToggle(false); - - return ( -
- - - - - - - {componentData && } - - - - - - -
- ); -}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx deleted file mode 100644 index dfba79f364..0000000000 --- a/src/course-outline/outline-sidebar/info-sidebar/SectionInfoSidebar.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useState } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Tab, Tabs } from '@openedx/paragon'; - -import { getItemIcon } from '@src/generic/block-type-utils'; - -import { SidebarTitle } from '@src/generic/sidebar'; - -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; -import Loading from '@src/generic/Loading'; -import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; -import { LifecycleSection } from '@src/course-lifecycle'; -import { InfoSection } from './InfoSection'; -import messages from '../messages'; - -interface Props { - sectionId: string; -} - -export const SectionSidebar = ({ sectionId }: Props) => { - const intl = useIntl(); - const [tab, setTab] = useState<'info' | 'settings'>('info'); - const { data: sectionData, isLoading } = useCourseItemData(sectionId); - const { clearSelection } = useOutlineSidebarContext(); - - if (isLoading) { - return ; - } - - return ( - <> - - - - - - - -
Settings
-
-
- - ); -}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx deleted file mode 100644 index f5fa04e2ce..0000000000 --- a/src/course-outline/outline-sidebar/info-sidebar/SubsectionInfoSidebar.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useState } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { Tab, Tabs } from '@openedx/paragon'; - -import { getItemIcon } from '@src/generic/block-type-utils'; - -import { SidebarTitle } from '@src/generic/sidebar'; - -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; -import Loading from '@src/generic/Loading'; -import { useOutlineSidebarContext } from '@src/course-outline/outline-sidebar/OutlineSidebarContext'; -import { LifecycleSection } from '@src/course-lifecycle'; -import { InfoSection } from './InfoSection'; -import messages from '../messages'; - -interface Props { - subsectionId: string; -} - -export const SubsectionSidebar = ({ subsectionId }: Props) => { - const intl = useIntl(); - const [tab, setTab] = useState<'info' | 'settings'>('info'); - const { data: subsectionData, isLoading } = useCourseItemData(subsectionId); - const { clearSelection } = useOutlineSidebarContext(); - - if (isLoading) { - return ; - } - - return ( - <> - - - - - - - -
Settings
-
-
- - ); -}; diff --git a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx b/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx deleted file mode 100644 index bf75fa60bb..0000000000 --- a/src/course-outline/outline-sidebar/info-sidebar/UnitInfoSidebar.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState } from 'react'; -import { useIntl } from '@edx/frontend-platform/i18n'; -import { - Button, Tab, Tabs, -} from '@openedx/paragon'; -import { - OpenInFull, -} from '@openedx/paragon/icons'; - -import { getItemIcon } from '@src/generic/block-type-utils'; - -import { SidebarTitle } from '@src/generic/sidebar'; - -import { useCourseItemData } from '@src/course-outline/data/apiHooks'; -import Loading from '@src/generic/Loading'; -import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import XBlockContainerIframe from '@src/course-unit/xblock-container-iframe'; -import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; -import { Link } from 'react-router-dom'; -import { LifecycleSection } from '@src/course-lifecycle'; -import { useOutlineSidebarContext } from '../OutlineSidebarContext'; -import messages from '../messages'; -import { InfoSection } from './InfoSection'; - -interface Props { - unitId: string; -} - -export const UnitSidebar = ({ unitId }: Props) => { - const intl = useIntl(); - const [tab, setTab] = useState<'preview' | 'info' | 'settings'>('info'); - const { data: unitData, isLoading } = useCourseItemData(unitId); - const { clearSelection } = useOutlineSidebarContext(); - const { getUnitUrl, courseId } = useCourseAuthoringContext(); - - if (isLoading) { - return ; - } - - return ( - <> - - - - - - - {}, handleDuplicate: () => {}, handleUnlink: () => {} }} - courseVerticalChildren={[]} - handleConfigureSubmit={() => {}} - readonly - /> - - - - - - -
Settings
-
-
- - ); -}; diff --git a/src/course-outline/section-card/SectionCard.tsx b/src/course-outline/section-card/SectionCard.tsx index 2cd5a645bd..41b8c24fdc 100644 --- a/src/course-outline/section-card/SectionCard.tsx +++ b/src/course-outline/section-card/SectionCard.tsx @@ -29,6 +29,7 @@ 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 { @@ -117,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(() => { @@ -138,6 +140,22 @@ const SectionCard = ({ 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; @@ -313,6 +331,8 @@ const SectionCard = ({ actions={actions} readyToSync={upstreamInfo?.readyToSync} canPublish={blockLifecycleState?.canPublish} + lifecycleState={blockLifecycleState?.state} + onClickLifecycle={openLifecycleModal} /> )}
@@ -376,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 46fd2adb64..7f515669fd 100644 --- a/src/course-outline/subsection-card/SubsectionCard.tsx +++ b/src/course-outline/subsection-card/SubsectionCard.tsx @@ -30,6 +30,7 @@ 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 { @@ -94,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 [ @@ -120,6 +122,19 @@ const SubsectionCard = ({ 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; @@ -318,6 +333,8 @@ const SubsectionCard = ({ 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 95fb67d1af..22e334db47 100644 --- a/src/course-outline/unit-card/UnitCard.tsx +++ b/src/course-outline/unit-card/UnitCard.tsx @@ -25,6 +25,7 @@ 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; @@ -75,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(); @@ -100,6 +102,19 @@ const UnitCard = ({ // 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; @@ -266,6 +281,8 @@ const UnitCard = ({ 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/unit-sidebar/unit-info/PublishControls.tsx b/src/course-unit/unit-sidebar/unit-info/PublishControls.tsx deleted file mode 100644 index eab6a5bd2f..0000000000 --- a/src/course-unit/unit-sidebar/unit-info/PublishControls.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { useDispatch, useSelector } from 'react-redux'; -import { Icon, Stack, useToggle } from '@openedx/paragon'; -import { InfoOutline as InfoOutlineIcon, Person } from '@openedx/paragon/icons'; -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import ModalNotification from '@src/generic/modal-notification'; -import { useIframe } from '@src/generic/hooks/context/hooks'; -import { getCourseUnitData } from '@src/course-unit/data/selectors'; -import { editCourseUnitVisibilityAndData } from '@src/course-unit/data/thunk'; -import { messageTypes, PUBLISH_TYPES } from '@src/course-unit/constants'; -import { SidebarFooter, SidebarHeader } from '@src/course-unit/legacy-sidebar/components'; -import useCourseUnitData from '@src/course-unit/legacy-sidebar/hooks'; -import ReleaseInfoComponent from '@src/course-unit/legacy-sidebar/components/ReleaseInfoComponent'; -import messages from './messages'; -import UnitVisibilityInfo from './UnitVisibilityInfo'; - -interface PublishControlsProps { - blockId?: string, - hideCopyButton?: boolean, - hidePublishButton?: boolean, -} - -const PublishControls = ({ - blockId, - hideCopyButton = false, - hidePublishButton = false, -}: PublishControlsProps) => { - const unitData = useSelector(getCourseUnitData); - const { - title, - locationId, - releaseLabel, - visibilityState, - visibleToStaffOnly, - publishCardClass, - } = useCourseUnitData(unitData); - const intl = useIntl(); - const { sendMessageToIframe } = useIframe(); - - const [isDiscardModalOpen, openDiscardModal, closeDiscardModal] = useToggle(false); - const [isVisibleModalOpen, openVisibleModal, closeVisibleModal] = useToggle(false); - - const { - editedOn, - editedBy, - publishedBy, - publishedOn, - } = unitData; - - const dispatch = useDispatch(); - - const handleCourseUnitVisibility = () => { - closeVisibleModal(); - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.republish, null)); - }; - - const handleCourseUnitDiscardChanges = () => { - closeDiscardModal(); - dispatch(editCourseUnitVisibilityAndData( - blockId, - PUBLISH_TYPES.discardChanges, - null, - null, - null, - /* istanbul ignore next */ - () => sendMessageToIframe(messageTypes.refreshXBlock, null), - )); - }; - - const handleCourseUnitPublish = () => { - dispatch(editCourseUnitVisibilityAndData(blockId, PUBLISH_TYPES.makePublic)); - }; - - return ( -
-
- -
- - - {editedOn && ( -
- - - - - {editedBy && ( - <> - - - {editedBy} - - - - - - - )} - - {editedOn} - - -
- )} - {publishedOn && ( -
- - - - - {publishedBy && ( - <> - - - {publishedBy} - - - - - - - )} - - {publishedOn} - - -
- )} -
- - - {releaseLabel} - -
- -
-
-
- -
-
- - - -
- ); -}; - -export default PublishControls; diff --git a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx b/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx deleted file mode 100644 index 953dfc268f..0000000000 --- a/src/course-unit/unit-sidebar/unit-info/UnitInfoSidebar.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; -import { useParams } from 'react-router-dom'; -import { ComponentCountSnippet, getItemIcon } from '@src/generic/block-type-utils'; -import { SidebarContent, SidebarSection, SidebarTitle } from '@src/generic/sidebar'; -import { useCallback, useEffect, useMemo } from 'react'; -import { Tag } from '@openedx/paragon/icons'; -import { ContentTagsSnippet } from '@src/content-tags-drawer'; -import configureMessages from '@src/generic/configure-modal/messages'; -import { - Button, ButtonGroup, Tab, Tabs, -} from '@openedx/paragon'; -import { useDispatch, useSelector } from 'react-redux'; -import { useIframe } from '@src/generic/hooks/context/hooks'; -import { AccessEditComponent, DiscussionEditComponent } from '@src/generic/configure-modal/UnitTab'; -import { Form, Formik } from 'formik'; -import { getCourseUnitData, getCourseVerticalChildren } from '@src/course-unit/data/selectors'; -import { messageTypes, PUBLISH_TYPES, UNIT_VISIBILITY_STATES } from '@src/course-unit/constants'; -import { editCourseUnitVisibilityAndData, fetchCourseSectionVerticalData } from '@src/course-unit/data/thunk'; -import { LifecycleSection } from '@src/course-lifecycle'; -import PublishControls from './PublishControls'; -import { useUnitSidebarContext } from '../UnitSidebarContext'; -import messages from './messages'; - -/** - * Component to show unit details: Publish status, Component counts and Content Tags. - * - * It's using in the details tab of the unit info sidebar. - */ -const UnitInfoDetails = () => { - const intl = useIntl(); - const { blockId } = useParams(); - const dispatch = useDispatch(); - const courseVerticalChildren = useSelector(getCourseVerticalChildren); - const { hasChanges } = useSelector(getCourseUnitData); - - if (blockId === undefined) { - // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. - throw new Error('Error: route is missing blockId.'); - } - - // After a lifecycle publish, re-fetch the unit's vertical data so PublishControls - // (editedOn, publishedOn, discard button) and the hasChanges prop reflect the new state. - const handlePublishSuccess = useCallback(() => { - dispatch(fetchCourseSectionVerticalData(blockId)); - }, [blockId, dispatch]); - - const componentData: Record = useMemo(() => ( - // @ts-ignore - courseVerticalChildren.children.reduce>( - (acc, { blockType }) => { - acc[blockType] = (acc[blockType] ?? 0) + 1; - return acc; - }, - {}, - ) - ), [courseVerticalChildren.children]); - - return ( - - - - - {componentData && } - - - - - - ); -}; - -/** - * Component with forms to edit unit settings. - * - * It's using in the settings tab of the unit info sidebar. - */ -const UnitInfoSettings = () => { - const dispatch = useDispatch(); - const intl = useIntl(); - const { sendMessageToIframe } = useIframe(); - const { - id, - visibilityState, - discussionEnabled, - userPartitionInfo, - } = useSelector(getCourseUnitData); - - const visibleToStaffOnly = visibilityState === UNIT_VISIBILITY_STATES.staffOnly; - - const handleUpdate = async ( - isVisible: boolean, - groupAccess: Record | null, - isDiscussionEnabled: boolean, - ) => { - // oxlint-disable-next-line @typescript-eslint/await-thenable - this dispatch() IS returning a promise. - await dispatch(editCourseUnitVisibilityAndData( - id, - PUBLISH_TYPES.republish, - isVisible, - groupAccess, - isDiscussionEnabled, - () => sendMessageToIframe(messageTypes.refreshXBlock, null), - id, - )); - }; - - const handleSaveGroups = async (data, { resetForm }) => { - const groupAccess = {}; - if (data.selectedPartitionIndex >= 0) { - const partitionId = userPartitionInfo.selectablePartitions[data.selectedPartitionIndex].id; - groupAccess[partitionId] = data.selectedGroups.map(g => parseInt(g, 10)); - } - await handleUpdate(visibleToStaffOnly, groupAccess, discussionEnabled); - resetForm({ values: data }); - }; - - /* istanbul ignore next */ - const getSelectedGroups = () => { - if (userPartitionInfo?.selectedPartitionIndex >= 0) { - return userPartitionInfo?.selectablePartitions[userPartitionInfo?.selectedPartitionIndex] - ?.groups - .filter(({ selected }) => selected) - // eslint-disable-next-line @typescript-eslint/no-shadow - .map(({ id }) => `${id}`) - || []; - } - return []; - }; - - const initialValues = useMemo(() => ( - { - selectedPartitionIndex: userPartitionInfo?.selectedPartitionIndex, - selectedGroups: getSelectedGroups(), - } - ), [userPartitionInfo]); - - return ( - - - - - - - - - - {({ - values, setFieldValue, dirty, - }) => ( -
- - {dirty && ( - - )} - - )} -
-
- - handleUpdate(visibleToStaffOnly, null, e.target.checked)} - /> - -
- ); -}; - -/** - * Component that renders the tabs of the info sidebar for units. - */ -export const UnitInfoSidebar = () => { - const intl = useIntl(); - const currentItemData = useSelector(getCourseUnitData); - const { - currentTabKey, - setCurrentTabKey, - } = useUnitSidebarContext(); - - useEffect(() => { - // Set default Tab key - setCurrentTabKey('details'); - }, []); - - return ( -
- - - -
- -
-
- -
- -
-
-
-
- ); -}; From 1a6d8c467bced3dfe15220e80e175dfcb9511135 Mon Sep 17 00:00:00 2001 From: eemaanamir Date: Wed, 8 Apr 2026 01:40:41 +0500 Subject: [PATCH 3/5] feat: add course lifecycle management UI --- .../components/BlockCommentsPanel.tsx | 4 +- .../components/LifecycleBadge.tsx | 2 +- .../components/LifecycleSection.tsx | 7 ++- src/course-lifecycle/data/api.ts | 8 +++ src/course-lifecycle/data/apiHooks.ts | 15 ++++- src/course-lifecycle/index.ts | 4 +- .../sidebar-footer/ActionButtons.tsx | 2 +- src/studio-home/card-item/index.tsx | 58 ++++++++++++------- src/studio-home/data/slice.ts | 2 + .../courses-lifecycle-filter-menu/index.jsx | 41 +++++++++++++ .../courses-tab/courses-filters/index.jsx | 7 +++ .../tabs-section/courses-tab/index.tsx | 14 ++++- 12 files changed, 135 insertions(+), 29 deletions(-) create mode 100644 src/studio-home/tabs-section/courses-tab/courses-filters/courses-lifecycle-filter-menu/index.jsx diff --git a/src/course-lifecycle/components/BlockCommentsPanel.tsx b/src/course-lifecycle/components/BlockCommentsPanel.tsx index 78bc8ac528..fa35edf980 100644 --- a/src/course-lifecycle/components/BlockCommentsPanel.tsx +++ b/src/course-lifecycle/components/BlockCommentsPanel.tsx @@ -8,7 +8,9 @@ import { } from '@openedx/paragon'; import { getAuthenticatedUser } from '@edx/frontend-platform/auth'; -import { useBlockComments, useCreateComment, useDeleteComment, useResolveComment } from '../data/apiHooks'; +import { + useBlockComments, useCreateComment, useDeleteComment, useResolveComment, +} from '../data/apiHooks'; interface Props { usageKey: string; diff --git a/src/course-lifecycle/components/LifecycleBadge.tsx b/src/course-lifecycle/components/LifecycleBadge.tsx index 0b38123b3f..135dbc0a62 100644 --- a/src/course-lifecycle/components/LifecycleBadge.tsx +++ b/src/course-lifecycle/components/LifecycleBadge.tsx @@ -20,4 +20,4 @@ export const LifecycleBadge = ({ state }: Props) => { {label} ); -}; \ No newline at end of file +}; diff --git a/src/course-lifecycle/components/LifecycleSection.tsx b/src/course-lifecycle/components/LifecycleSection.tsx index b0349009be..9aa5d1f4db 100644 --- a/src/course-lifecycle/components/LifecycleSection.tsx +++ b/src/course-lifecycle/components/LifecycleSection.tsx @@ -48,7 +48,12 @@ export const LifecycleSection = ({ {!isLoading && blockState && effectiveState && ( <> - + )} diff --git a/src/course-lifecycle/data/api.ts b/src/course-lifecycle/data/api.ts index c5fe3fc957..f309e2836e 100644 --- a/src/course-lifecycle/data/api.ts +++ b/src/course-lifecycle/data/api.ts @@ -20,6 +20,14 @@ export async function getCourseAggregateState(courseId: 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`, diff --git a/src/course-lifecycle/data/apiHooks.ts b/src/course-lifecycle/data/apiHooks.ts index ac73c81282..0c608fe2ee 100644 --- a/src/course-lifecycle/data/apiHooks.ts +++ b/src/course-lifecycle/data/apiHooks.ts @@ -10,6 +10,7 @@ import { deleteComment, getBlockComments, getBlockState, + getBulkCourseAggregateStates, getCourseAggregateState, getCourseComments, publishBlock, @@ -26,11 +27,23 @@ export const lifecycleQueryKeys = { 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 useCourseAggregateState = (courseId: string) => useQuery({ +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) => { diff --git a/src/course-lifecycle/index.ts b/src/course-lifecycle/index.ts index 2f35cf6a32..4c687e8cda 100644 --- a/src/course-lifecycle/index.ts +++ b/src/course-lifecycle/index.ts @@ -16,4 +16,6 @@ export { usePublishCourse, lifecycleQueryKeys, } from './data/apiHooks'; -export type { BlockReviewState, BlockReviewComment, LifecycleState, CourseAggregateState } from './data/types'; +export type { + BlockReviewState, BlockReviewComment, LifecycleState, CourseAggregateState, +} from './data/types'; diff --git a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx index 9e842a3a60..3557197756 100644 --- a/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx +++ b/src/course-unit/sidebar/components/sidebar-footer/ActionButtons.tsx @@ -52,7 +52,7 @@ const ActionButtons = ({ {intl.formatMessage(messages.actionButtonDiscardChangesTitle)} )} - {enableCopyPasteUnits && canEdit && ( + {!hideCopyButton && enableCopyPasteUnits && canEdit && ( <>