From f4d20eba457bdfe80634ebaa9747eef50825bf8c Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 22 Dec 2025 23:24:54 +0530 Subject: [PATCH] feat: bulk update legacy library references (#2764) Shows an alert in course outline and review tab of course libraries page when the course contains legacy library content blocks that depend on libraries that are already migrated to library v2, i.e. the blocks are ready to be converted into item banks that can make use of these new v2 libraries. Authors can click on a single button to convert all references in a single go. The button launches a background task which is then polled by the frontend and the status is presented to the Author. --- src/course-libraries/CourseLibraries.tsx | 12 +- .../LegacyLibContentBlockAlert.test.tsx | 83 ++++++++++++++ .../LegacyLibContentBlockAlert.tsx | 87 +++++++++++++++ src/course-libraries/data/api.mocks.ts | 104 ++++++++++++++++++ src/course-libraries/data/api.ts | 39 +++++++ src/course-libraries/data/apiHooks.test.tsx | 36 +++++- src/course-libraries/data/apiHooks.ts | 44 +++++++- src/course-libraries/messages.ts | 35 ++++++ src/course-outline/CourseOutline.tsx | 2 + src/course-outline/data/api.ts | 2 + src/course-outline/data/apiHooks.ts | 15 ++- src/data/constants.ts | 9 ++ src/data/types.ts | 18 +++ 13 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 src/course-libraries/LegacyLibContentBlockAlert.test.tsx create mode 100644 src/course-libraries/LegacyLibContentBlockAlert.tsx diff --git a/src/course-libraries/CourseLibraries.tsx b/src/course-libraries/CourseLibraries.tsx index 7ab6a1bdf..4021303df 100644 --- a/src/course-libraries/CourseLibraries.tsx +++ b/src/course-libraries/CourseLibraries.tsx @@ -23,16 +23,17 @@ import { import sumBy from 'lodash/sumBy'; import { useSearchParams } from 'react-router-dom'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; -import getPageHeadTitle from '../generic/utils'; +import { useStudioHome } from '@src/studio-home/hooks'; +import NewsstandIcon from '@src/generic/NewsstandIcon'; +import getPageHeadTitle from '@src/generic/utils'; +import SubHeader from '@src/generic/sub-header/SubHeader'; +import Loading from '@src/generic/Loading'; import messages from './messages'; -import SubHeader from '../generic/sub-header/SubHeader'; import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks'; import type { PublishableEntityLinkSummary } from './data/api'; -import Loading from '../generic/Loading'; -import { useStudioHome } from '../studio-home/hooks'; -import NewsstandIcon from '../generic/NewsstandIcon'; import ReviewTabContent from './ReviewTabContent'; import { OutOfSyncAlert } from './OutOfSyncAlert'; +import LegacyLibContentBlockAlert from './LegacyLibContentBlockAlert'; interface LibraryCardProps { linkSummary: PublishableEntityLinkSummary; @@ -233,6 +234,7 @@ export const CourseLibraries = () => { notification={outOfSyncCount} className="px-2 mt-3" > + {renderReviewTabContent()} diff --git a/src/course-libraries/LegacyLibContentBlockAlert.test.tsx b/src/course-libraries/LegacyLibContentBlockAlert.test.tsx new file mode 100644 index 000000000..661546552 --- /dev/null +++ b/src/course-libraries/LegacyLibContentBlockAlert.test.tsx @@ -0,0 +1,83 @@ +import { initializeMocks, render, screen } from '@src/testUtils'; +import { userEvent } from '@testing-library/user-event'; +import { waitFor } from '@testing-library/react'; +import LegacyLibContentBlockAlert from './LegacyLibContentBlockAlert'; +import * as apiMocks from './data/api.mocks'; + +const renderComponent = (courseId: string) => { + render( + , + ); +}; + +apiMocks.mockGetReadyToUpdateReferences.applyMock(); +apiMocks.mockMigrateCourseReadyToMigrateLegacyLibContentBlocks.applyMock(); +apiMocks.mockGetCourseLegacyLibRefUpdateTaskStatus.applyMock(); +let mockShowToast; + +describe('LegacyLibContentBlockAlert', () => { + beforeEach(() => { + const mocks = initializeMocks(); + mockShowToast = mocks.mockShowToast; + }); + + test('alert is not rendered when data is loading', async () => { + renderComponent(apiMocks.mockGetReadyToUpdateReferences.courseKeyLoading); + expect(await screen.findByTestId('redux-provider')).toBeEmptyDOMElement(); + }); + + test('alert is not rendered when data is empty', async () => { + renderComponent(apiMocks.mockGetReadyToUpdateReferences.courseKeyEmpty); + expect(await screen.findByTestId('redux-provider')).toBeEmptyDOMElement(); + }); + + test('alert is rendered when 1 block is present', async () => { + renderComponent(apiMocks.mockGetReadyToUpdateReferences.courseKeyWith1Block); + expect(await screen.findByText('This course contains 1 legacy library reference')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Update library references' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Learn more' })).toBeInTheDocument(); + }); + + test('alert is rendered when 2 blocks are present', async () => { + renderComponent(apiMocks.mockGetReadyToUpdateReferences.courseKeyWith2Blocks); + expect(await screen.findByText('This course contains 2 legacy library references')).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: 'Update library references' })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: 'Learn more' })).toBeInTheDocument(); + }); + + test('migrates all blocks successfully', async () => { + const user = userEvent.setup(); + renderComponent(apiMocks.mockGetReadyToUpdateReferences.courseKeyWith1Block); + expect(await screen.findByText('This course contains 1 legacy library reference')).toBeInTheDocument(); + const actionBtn = await screen.findByRole('button', { name: 'Update library references' }); + expect(actionBtn).toBeInTheDocument(); + await user.click(actionBtn); + + // mockShowToast will have been called + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Updating library references...'); + }); + // mockShowToast will have been called + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Successfully updated all legacy library references'); + }); + }); + + test('migrates all blocks: failed', async () => { + const user = userEvent.setup(); + renderComponent(apiMocks.mockGetReadyToUpdateReferences.courseKeyWith3Blocks); + expect(await screen.findByText('This course contains 3 legacy library references')).toBeInTheDocument(); + const actionBtn = await screen.findByRole('button', { name: 'Update library references' }); + expect(actionBtn).toBeInTheDocument(); + await user.click(actionBtn); + + // mockShowToast will have been called + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Updating library references...'); + }); + // mockShowToast will have been called + await waitFor(() => { + expect(mockShowToast).toHaveBeenCalledWith('Failed to update legacy library references'); + }); + }); +}); diff --git a/src/course-libraries/LegacyLibContentBlockAlert.tsx b/src/course-libraries/LegacyLibContentBlockAlert.tsx new file mode 100644 index 000000000..a5092ff0a --- /dev/null +++ b/src/course-libraries/LegacyLibContentBlockAlert.tsx @@ -0,0 +1,87 @@ +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Hyperlink } from '@openedx/paragon'; +import { + useContext, useEffect, useMemo, useState, +} from 'react'; +import { UserTaskStatus } from '@src/data/constants'; +import AlertMessage from '@src/generic/alert-message'; +import LoadingButton from '@src/generic/loading-button'; +import { ToastContext } from '@src/generic/toast-context'; +import { + useCheckMigrateCourseLegacyLibReadyToMigrateBlocksOptions, + useCourseLegacyLibReadyToMigrateBlocks, + useMigrateCourseLegacyLibReadyToMigrateBlocks, +} from './data/apiHooks'; +import messages from './messages'; + +interface Props { + courseId: string, +} + +const LegacyLibContentBlockAlert = ({ courseId }: Props) => { + const intl = useIntl(); + const { showToast } = useContext(ToastContext); + const [taskId, setTaskId] = useState(undefined); + const { data, isPending, refetch } = useCourseLegacyLibReadyToMigrateBlocks(courseId); + const { mutateAsync } = useMigrateCourseLegacyLibReadyToMigrateBlocks(courseId); + const taskStatus = useCheckMigrateCourseLegacyLibReadyToMigrateBlocksOptions(courseId, taskId); + const learnMoreUrl = 'https://docs.openedx.org/en/latest/educators/how-tos/course_development/migrate_legacy_libraries.html#id8'; + + useEffect(() => { + if (taskStatus.data?.state === UserTaskStatus.Succeeded) { + showToast(intl.formatMessage(messages.legacyLibReadyToMigrateTaskCompleted)); + setTaskId(undefined); + refetch(); + } else if (taskStatus.data?.state === UserTaskStatus.Failed + || taskStatus.data?.state === UserTaskStatus.Cancelled) { + showToast(intl.formatMessage(messages.legacyLibReadyToMigrateTaskFailed)); + setTaskId(undefined); + refetch(); + } else if (taskId) { + showToast(intl.formatMessage(messages.legacyLibReadyToMigrateTaskInProgress)); + } + }, [taskStatus, taskId, refetch]); + + const migrateFn = async () => { + await mutateAsync(undefined, { + onSuccess: (result) => { + setTaskId(result.uuid); + }, + onError: () => { + setTaskId(undefined); + }, + }); + }; + + const alertCount = useMemo(() => data?.length || 0, [data]); + + if (isPending || taskId) { + return null; + } + + return ( + 0} + variant="info" + actions={[ + , + , + ]} + /> + ); +}; + +export default LegacyLibContentBlockAlert; diff --git a/src/course-libraries/data/api.mocks.ts b/src/course-libraries/data/api.mocks.ts index b4d2fc9a1..588886d10 100644 --- a/src/course-libraries/data/api.mocks.ts +++ b/src/course-libraries/data/api.mocks.ts @@ -4,6 +4,7 @@ import fetchMock from 'fetch-mock-jest'; import * as libApi from '@src/library-authoring/data/api'; import { createAxiosError } from '@src/testUtils'; +import { UserTaskStatus } from '@src/data/constants'; import mockLinksResult from '../__mocks__/publishableEntityLinks.json'; import mockSummaryResult from '../__mocks__/linkCourseSummary.json'; import mockLinkDetailsFromIndex from '../__mocks__/linkDetailsFromIndex.json'; @@ -105,3 +106,106 @@ export async function mockUseLibBlockMetadata() { mockUseLibBlockMetadata.applyMock = () => { jest.spyOn(libApi, 'getLibraryBlockMetadata').mockImplementation(mockUseLibBlockMetadata); }; + +/** + * Mock getCourseReadyToMigrateLegacyLibContentBlocks +*/ +export async function mockGetReadyToUpdateReferences( + courseId?: string, +): ReturnType { + switch (courseId) { + case mockGetReadyToUpdateReferences.courseKeyLoading: + return new Promise(() => {}); + case mockGetReadyToUpdateReferences.courseKeyEmpty: + return Promise.resolve([]); + case mockGetReadyToUpdateReferences.courseKeyWith2Blocks: + return Promise.resolve([{ usageKey: 'some-key-1' }, { usageKey: 'some-key-2' }]); + case mockGetReadyToUpdateReferences.courseKeyWith3Blocks: + return Promise.resolve([{ usageKey: 'some-key-1' }, { usageKey: 'some-key-2' }, { usageKey: 'some-key-3' }]); + case mockGetReadyToUpdateReferences.courseKeyWith1Block: + return Promise.resolve([{ usageKey: 'some-key-1' }]); + default: + throw Error(); + } +} +mockGetReadyToUpdateReferences.courseKeyLoading = 'course-v1:loading+1+1'; +mockGetReadyToUpdateReferences.courseKeyEmpty = 'course-v1:empty+1+1'; +mockGetReadyToUpdateReferences.courseKeyWith2Blocks = 'course-v1:normal+2+2'; +mockGetReadyToUpdateReferences.courseKeyWith1Block = 'course-v1:normal+1+1'; +mockGetReadyToUpdateReferences.courseKeyWith3Blocks = 'course-v1:normal+3+3'; +mockGetReadyToUpdateReferences.applyMock = () => { + jest.spyOn(api, 'getCourseReadyToMigrateLegacyLibContentBlocks').mockImplementation(mockGetReadyToUpdateReferences); +}; + +/** + * Mock getCourseLegacyLibRefUpdateTaskStatus +*/ +export async function mockGetCourseLegacyLibRefUpdateTaskStatus( + _courseId?: string, + taskId?: string, +): ReturnType { + switch (taskId) { + case mockGetCourseLegacyLibRefUpdateTaskStatus.taskInProgress: + return Promise.resolve({ + state: UserTaskStatus.InProgress, + stateText: 'In Progress', + uuid: 'task-pending', + } as unknown as ReturnType); + case mockGetCourseLegacyLibRefUpdateTaskStatus.taskComplete: + return Promise.resolve({ + state: UserTaskStatus.Succeeded, + stateText: 'Succeeded', + uuid: 'task-complete', + } as unknown as ReturnType); + case mockGetCourseLegacyLibRefUpdateTaskStatus.taskFailed: + return Promise.resolve({ + state: UserTaskStatus.Failed, + stateText: 'Failed', + uuid: 'task-failed', + } as unknown as ReturnType); + default: + throw Error(); + } +} +mockGetCourseLegacyLibRefUpdateTaskStatus.taskInProgress = 'task-pending'; +mockGetCourseLegacyLibRefUpdateTaskStatus.taskComplete = 'task-complete'; +mockGetCourseLegacyLibRefUpdateTaskStatus.taskFailed = 'task-failed'; +mockGetCourseLegacyLibRefUpdateTaskStatus.applyMock = () => { + jest.spyOn(api, 'getCourseLegacyLibRefUpdateTaskStatus').mockImplementation(mockGetCourseLegacyLibRefUpdateTaskStatus); +}; + +/** + * Mock getCourseReadyToMigrateLegacyLibContentBlocks +*/ +export async function mockMigrateCourseReadyToMigrateLegacyLibContentBlocks( + courseId?: string, +): ReturnType { + switch (courseId) { + case mockGetReadyToUpdateReferences.courseKeyWith2Blocks: + return Promise.resolve({ + state: UserTaskStatus.InProgress, + stateText: 'In Progress', + uuid: 'task-pending', + } as unknown as ReturnType); + case mockGetReadyToUpdateReferences.courseKeyWith1Block: + return Promise.resolve({ + state: UserTaskStatus.InProgress, + stateText: 'In Progress', + uuid: 'task-complete', + } as unknown as ReturnType); + case mockGetReadyToUpdateReferences.courseKeyWith3Blocks: + return Promise.resolve({ + state: UserTaskStatus.InProgress, + stateText: 'In Progress', + uuid: 'task-failed', + } as unknown as ReturnType); + default: + throw Error(); + } +} +mockMigrateCourseReadyToMigrateLegacyLibContentBlocks.applyMock = () => { + jest.spyOn( + api, + 'migrateCourseReadyToMigrateLegacyLibContentBlocks', + ).mockImplementation(mockMigrateCourseReadyToMigrateLegacyLibContentBlocks); +}; diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index 03b01b2c8..5806ec47e 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -1,10 +1,13 @@ import { camelCaseObject, getConfig } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import type { UsageKeyBlock, UserTaskStatusWithUuid } from '@src/data/types'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`; export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`; +export const courseLegacyLibraryContentBlocks = (courseId: string) => `${getApiBaseUrl()}/api/courses/v1/migrate_legacy_content_blocks/${courseId}/`; +export const courseLegacyLibraryContentTaskStatus = (courseId: string, taskId: string) => `${courseLegacyLibraryContentBlocks(courseId)}${taskId}/`; export interface PaginatedData { next: string | null; @@ -81,3 +84,39 @@ export const getEntityLinksSummaryByDownstreamContext = async ( .get(getEntityLinksSummaryByDownstreamContextUrl(downstreamContextKey)); return camelCaseObject(data); }; + +/** + * Get all legacy library blocks that ready to migrate to library v2 item bank in given course + */ +export async function getCourseReadyToMigrateLegacyLibContentBlocks(courseId: string): Promise { + const { data } = await getAuthenticatedHttpClient() + .get(courseLegacyLibraryContentBlocks(courseId)); + + return camelCaseObject(data); +} + +// istanbul ignore next +/** + * Migrate legacy library blocks that ready to migrate to library v2 item bank in given course + */ +export async function migrateCourseReadyToMigrateLegacyLibContentBlocks( + courseId: string, +): Promise { + const { data } = await getAuthenticatedHttpClient() + .post(courseLegacyLibraryContentBlocks(courseId)); + + return camelCaseObject(data); +} + +/** + * Get task status of legacy library blocks reference update task. + */ +export async function getCourseLegacyLibRefUpdateTaskStatus( + courseId: string, + taskId: string, +): Promise { + const { data } = await getAuthenticatedHttpClient() + .get(courseLegacyLibraryContentTaskStatus(courseId, taskId)); + + return camelCaseObject(data); +} diff --git a/src/course-libraries/data/apiHooks.test.tsx b/src/course-libraries/data/apiHooks.test.tsx index 4f8c5f9a9..418ff3d5b 100644 --- a/src/course-libraries/data/apiHooks.test.tsx +++ b/src/course-libraries/data/apiHooks.test.tsx @@ -3,8 +3,8 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import MockAdapter from 'axios-mock-adapter'; import { renderHook, waitFor } from '@testing-library/react'; -import { getEntityLinksByDownstreamContextUrl } from './api'; -import { useEntityLinks } from './apiHooks'; +import { courseLegacyLibraryContentBlocks, courseLegacyLibraryContentTaskStatus, getEntityLinksByDownstreamContextUrl } from './api'; +import { useCheckMigrateCourseLegacyLibReadyToMigrateBlocksOptions, useCourseLegacyLibReadyToMigrateBlocks, useEntityLinks } from './apiHooks'; let axiosMock: MockAdapter; @@ -74,4 +74,36 @@ describe('course libraries api hooks', () => { content_type: undefined, }); }); + + it('should return ready to migrate blocks', async () => { + const courseId = 'course-v1:some+key'; + const url = courseLegacyLibraryContentBlocks(courseId); + axiosMock.onGet(url).reply(200, []); + const { result } = renderHook(() => useCourseLegacyLibReadyToMigrateBlocks(courseId), { wrapper }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(axiosMock.history.get[0].url).toEqual(url); + }); + + it('should check tasks status', async () => { + const courseId = 'course-v1:some+key'; + const taskId = 'some-id'; + const uuid = '1f8831dd-6f90-48df-a503-c0d0e957a331'; + const url = courseLegacyLibraryContentTaskStatus(courseId, taskId); + axiosMock.onGet(url).reply(200, { + task_id: 'some-id', + status: 'Succeeded', + status_text: 'Succeeded', + uuid, + }); + const { result } = renderHook(() => useCheckMigrateCourseLegacyLibReadyToMigrateBlocksOptions(courseId, taskId), { + wrapper, + }); + await waitFor(() => { + expect(result.current.isLoading).toBeFalsy(); + }); + expect(axiosMock.history.get[0].url).toEqual(url); + expect(result.current.data?.uuid).toEqual(uuid); + }); }); diff --git a/src/course-libraries/data/apiHooks.ts b/src/course-libraries/data/apiHooks.ts index 78920339c..51264cf14 100644 --- a/src/course-libraries/data/apiHooks.ts +++ b/src/course-libraries/data/apiHooks.ts @@ -1,8 +1,19 @@ import { type QueryClient, useQuery, + skipToken, + useMutation, + UseQueryResult, } from '@tanstack/react-query'; -import { getEntityLinksSummaryByDownstreamContext, getEntityLinks } from './api'; +import { UserTaskStatus } from '@src/data/constants'; +import type { UserTaskStatusWithUuid } from '@src/data/types'; +import { + getEntityLinksSummaryByDownstreamContext, + getEntityLinks, + getCourseReadyToMigrateLegacyLibContentBlocks, + migrateCourseReadyToMigrateLegacyLibContentBlocks, + getCourseLegacyLibRefUpdateTaskStatus, +} from './api'; export const courseLibrariesQueryKeys = { all: ['courseLibraries'], @@ -32,6 +43,12 @@ export const courseLibrariesQueryKeys = { return key; }, courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'], + legacyLibReadyToMigrateBlocks: (courseId: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'legacyLibReadyToMigrateBlocks'], + legacyLibReadyToMigrateBlocksStatus: (courseId: string, taskId?: string) => [ + ...courseLibrariesQueryKeys.legacyLibReadyToMigrateBlocks(courseId), + 'status', + { taskId }, + ], }; export const useEntityLinks = ({ @@ -80,3 +97,28 @@ export const invalidateLinksQuery = (queryClient: QueryClient, courseId: string) queryKey: courseLibrariesQueryKeys.courseLibraries(courseId), }); }; + +export const useCourseLegacyLibReadyToMigrateBlocks = (courseId: string, enabled: boolean = true) => ( + useQuery({ + queryKey: courseLibrariesQueryKeys.legacyLibReadyToMigrateBlocks(courseId), + queryFn: enabled && courseId ? () => getCourseReadyToMigrateLegacyLibContentBlocks(courseId) : skipToken, + }) +); + +export const useMigrateCourseLegacyLibReadyToMigrateBlocks = (courseId: string) => useMutation({ + mutationFn: () => migrateCourseReadyToMigrateLegacyLibContentBlocks(courseId), + gcTime: 60, // Cache for 1 minute to prevent rapid re-run of updating references +}); + +export const useCheckMigrateCourseLegacyLibReadyToMigrateBlocksOptions = ( + courseId: string, + taskId?: string, +): UseQueryResult => useQuery({ + queryKey: courseLibrariesQueryKeys.legacyLibReadyToMigrateBlocksStatus(courseId, taskId), + queryFn: taskId ? () => getCourseLegacyLibRefUpdateTaskStatus(courseId, taskId) : skipToken, + refetchInterval: (query) => ([ + UserTaskStatus.Succeeded, + UserTaskStatus.Failed, + UserTaskStatus.Cancelled, + ].includes(query.state.data?.state || UserTaskStatus.InProgress) ? false : 2000), +}); diff --git a/src/course-libraries/messages.ts b/src/course-libraries/messages.ts index efd3bb7a2..25ef757a8 100644 --- a/src/course-libraries/messages.ts +++ b/src/course-libraries/messages.ts @@ -121,6 +121,41 @@ const messages = defineMessages({ defaultMessage: 'View Section in Course', description: 'Label of the button to see the section in the course', }, + legacyLibReadyToMigrateAlertTitle: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.title', + defaultMessage: 'This course contains {count, plural, one {# legacy library reference} other {# legacy library references}}', + description: 'Title of alert shown when course contains legacy library content references', + }, + legacyLibReadyToMigrateAlertDescription: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.description', + defaultMessage: 'Legacy library references will no longer be supported and need to be updated to receive future changes.', + description: 'Description of alert shown when course contains legacy library content references', + }, + legacyLibReadyToMigrateAlertLearnMoreBtn: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.learnMoreBtn', + defaultMessage: 'Learn more', + description: 'Learn more button text of alert shown when course contains legacy library content references', + }, + legacyLibReadyToMigrateAlertActionBtn: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.actionBtn', + defaultMessage: 'Update library references', + description: 'Action button text of alert shown when course contains legacy library content references', + }, + legacyLibReadyToMigrateTaskCompleted: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.task.completed', + defaultMessage: 'Successfully updated all legacy library references', + description: 'Toast text when all legacy library references are updated.', + }, + legacyLibReadyToMigrateTaskFailed: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.task.failed', + defaultMessage: 'Failed to update legacy library references', + description: 'Toast text when legacy library references fail to update.', + }, + legacyLibReadyToMigrateTaskInProgress: { + id: 'course-authoring.course-libraries.legacyLibReadyToMigrate.alert.task.in-progress', + defaultMessage: 'Updating library references...', + description: 'Toast text when updating legacy library references is in progress.', + }, }); export default messages; diff --git a/src/course-outline/CourseOutline.tsx b/src/course-outline/CourseOutline.tsx index e629e289b..0406b95e5 100644 --- a/src/course-outline/CourseOutline.tsx +++ b/src/course-outline/CourseOutline.tsx @@ -41,6 +41,7 @@ import { NOTIFICATION_MESSAGES } from '@src/constants'; import { COMPONENT_TYPES } from '@src/generic/block-type-utils/constants'; import { XBlock } from '@src/data/types'; import { useCourseAuthoringContext } from '@src/CourseAuthoringContext'; +import LegacyLibContentBlockAlert from '@src/course-libraries/LegacyLibContentBlockAlert'; import { getCurrentItem, getProctoredExamsFlag, @@ -306,6 +307,7 @@ const CourseOutline = () => { savingStatus={savingStatus} errors={errors} /> + {showSuccessAlert ? ( `${getXBlockBaseApiUrl()} export const getXBlockApiUrl = (blockId: string) => `${getXBlockBaseApiUrl()}outline/${blockId}`; export const exportTags = (courseId: string) => `${getApiBaseUrl()}/api/content_tagging/v1/object_tags/${courseId}/export/`; export const createDiscussionsTopicsUrl = (courseId: string) => `${getApiBaseUrl()}/api/discussions/v0/course/${courseId}/sync_discussion_topics`; +export const courseLegacyLibraryContentBlocks = (courseId: string) => `${getApiBaseUrl()}/api/courses/v1/migrate_legacy_content_blocks/${courseId}/`; +export const courseLegacyLibraryContentTaskStatus = (courseId: string, taskId: string) => `${courseLegacyLibraryContentBlocks(courseId)}${taskId}/`; /** * Get course outline index. diff --git a/src/course-outline/data/apiHooks.ts b/src/course-outline/data/apiHooks.ts index fd4b76827..67a20acde 100644 --- a/src/course-outline/data/apiHooks.ts +++ b/src/course-outline/data/apiHooks.ts @@ -1,6 +1,11 @@ -import { skipToken, useMutation, useQuery } from '@tanstack/react-query'; +import { + skipToken, useMutation, useQuery, +} from '@tanstack/react-query'; import { createCourseXblock } from '@src/course-unit/data/api'; -import { getCourseDetails, getCourseItem } from './api'; +import { + getCourseDetails, + getCourseItem, +} from './api'; export const courseOutlineQueryKeys = { all: ['courseOutline'], @@ -10,6 +15,12 @@ export const courseOutlineQueryKeys = { contentLibrary: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId], courseItemId: (itemId?: string) => [...courseOutlineQueryKeys.all, itemId], courseDetails: (courseId?: string) => [...courseOutlineQueryKeys.all, courseId, 'details'], + legacyLibReadyToMigrateBlocks: (courseId: string) => [...courseOutlineQueryKeys.all, courseId, 'legacyLibReadyToMigrateBlocks'], + legacyLibReadyToMigrateBlocksStatus: (courseId: string, taskId?: string) => [ + ...courseOutlineQueryKeys.legacyLibReadyToMigrateBlocks(courseId), + 'status', + { taskId }, + ], }; /** diff --git a/src/data/constants.ts b/src/data/constants.ts index 2fc511d85..1bd4d70f7 100644 --- a/src/data/constants.ts +++ b/src/data/constants.ts @@ -61,3 +61,12 @@ export const VisibilityTypes = { export const TOTAL_LENGTH_KEY = 'total-length'; export const MAX_TOTAL_LENGTH = 65; + +export enum UserTaskStatus { + Pending = 'Pending', + Succeeded = 'Succeeded', + Failed = 'Failed', + InProgress = 'In Progress', + Cancelled = 'Cancelled', + Retrying = 'Retrying', +} diff --git a/src/data/types.ts b/src/data/types.ts index 95fe26471..246f8c8c9 100644 --- a/src/data/types.ts +++ b/src/data/types.ts @@ -1,3 +1,5 @@ +import { UserTaskStatus } from './constants'; + export interface GroupTypes { id: number; name: string; @@ -135,3 +137,19 @@ export interface OutlinePageErrors { sectionLoadingApi?: OutlineError | null, courseLaunchApi?: OutlineError | null, } + +export interface UsageKeyBlock { + usageKey: string; +} + +export interface UserTaskStatusWithUuid { + name: string; + state: UserTaskStatus; + stateText: string; + completedSteps: number; + totalSteps: number; + attempts: number; + created: string; + modified: string; + uuid: string; +}