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.
This commit is contained in:
Navin Karkera
2025-12-22 23:24:54 +05:30
committed by GitHub
parent 68a4b04475
commit f4d20eba45
13 changed files with 476 additions and 10 deletions

View File

@@ -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"
>
<LegacyLibContentBlockAlert courseId={courseId} />
{renderReviewTabContent()}
</Tab>
</Tabs>

View File

@@ -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(
<LegacyLibContentBlockAlert courseId={courseId} />,
);
};
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');
});
});
});

View File

@@ -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<string | undefined>(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 (
<AlertMessage
title={intl.formatMessage(messages.legacyLibReadyToMigrateAlertTitle, { count: alertCount })}
description={intl.formatMessage(messages.legacyLibReadyToMigrateAlertDescription)}
show={alertCount > 0}
variant="info"
actions={[
<Button
target="_blank"
as={Hyperlink}
variant="tertiary"
showLaunchIcon={false}
destination={learnMoreUrl}
>
{intl.formatMessage(messages.legacyLibReadyToMigrateAlertLearnMoreBtn)}
</Button>,
<LoadingButton
onClick={migrateFn}
label={intl.formatMessage(messages.legacyLibReadyToMigrateAlertActionBtn)}
/>,
]}
/>
);
};
export default LegacyLibContentBlockAlert;

View File

@@ -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<typeof api.getCourseReadyToMigrateLegacyLibContentBlocks> {
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<typeof api.getCourseLegacyLibRefUpdateTaskStatus> {
switch (taskId) {
case mockGetCourseLegacyLibRefUpdateTaskStatus.taskInProgress:
return Promise.resolve({
state: UserTaskStatus.InProgress,
stateText: 'In Progress',
uuid: 'task-pending',
} as unknown as ReturnType<typeof api.getCourseLegacyLibRefUpdateTaskStatus>);
case mockGetCourseLegacyLibRefUpdateTaskStatus.taskComplete:
return Promise.resolve({
state: UserTaskStatus.Succeeded,
stateText: 'Succeeded',
uuid: 'task-complete',
} as unknown as ReturnType<typeof api.getCourseLegacyLibRefUpdateTaskStatus>);
case mockGetCourseLegacyLibRefUpdateTaskStatus.taskFailed:
return Promise.resolve({
state: UserTaskStatus.Failed,
stateText: 'Failed',
uuid: 'task-failed',
} as unknown as ReturnType<typeof api.getCourseLegacyLibRefUpdateTaskStatus>);
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<typeof api.migrateCourseReadyToMigrateLegacyLibContentBlocks> {
switch (courseId) {
case mockGetReadyToUpdateReferences.courseKeyWith2Blocks:
return Promise.resolve({
state: UserTaskStatus.InProgress,
stateText: 'In Progress',
uuid: 'task-pending',
} as unknown as ReturnType<typeof api.migrateCourseReadyToMigrateLegacyLibContentBlocks>);
case mockGetReadyToUpdateReferences.courseKeyWith1Block:
return Promise.resolve({
state: UserTaskStatus.InProgress,
stateText: 'In Progress',
uuid: 'task-complete',
} as unknown as ReturnType<typeof api.migrateCourseReadyToMigrateLegacyLibContentBlocks>);
case mockGetReadyToUpdateReferences.courseKeyWith3Blocks:
return Promise.resolve({
state: UserTaskStatus.InProgress,
stateText: 'In Progress',
uuid: 'task-failed',
} as unknown as ReturnType<typeof api.migrateCourseReadyToMigrateLegacyLibContentBlocks>);
default:
throw Error();
}
}
mockMigrateCourseReadyToMigrateLegacyLibContentBlocks.applyMock = () => {
jest.spyOn(
api,
'migrateCourseReadyToMigrateLegacyLibContentBlocks',
).mockImplementation(mockMigrateCourseReadyToMigrateLegacyLibContentBlocks);
};

View File

@@ -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<T> {
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<UsageKeyBlock[]> {
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<UserTaskStatusWithUuid> {
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<UserTaskStatusWithUuid> {
const { data } = await getAuthenticatedHttpClient()
.get(courseLegacyLibraryContentTaskStatus(courseId, taskId));
return camelCaseObject(data);
}

View File

@@ -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);
});
});

View File

@@ -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<UserTaskStatusWithUuid> => 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),
});

View File

@@ -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;

View File

@@ -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}
/>
<LegacyLibContentBlockAlert courseId={courseId} />
<TransitionReplace>
{showSuccessAlert ? (
<AlertMessage

View File

@@ -44,6 +44,8 @@ export const getCourseItemApiUrl = (itemId: string) => `${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.

View File

@@ -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 },
],
};
/**

View File

@@ -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',
}

View File

@@ -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;
}