feat: Add ability to create Legacy Libraries (#2551)

This adds a CreateLegacyLibrary component. It functions the same as
CreateLibrary, but it calls the V1 (legacy) creation REST API rather the V2
(new/beta) REST API.

This reinstates, in the MFE, something that was possible using the legacy
frontend until it was prematurely removed by
https://github.com/openedx/edx-platform/pull/37454. 

We plan to re-remove this ability between Ulmo and Verawood as part of:
https://github.com/openedx/edx-platform/issues/32457.
So, we have intentionally avoided factoring out common logic between
CreateLibrary and CreateLegacyLibrary, ensuring that the latter
remains easy to remove and clean up.
This commit is contained in:
Kyle McCormick
2025-10-22 17:33:59 -04:00
committed by GitHub
parent 46fa17ea83
commit 5ce61fa5e5
10 changed files with 489 additions and 9 deletions

View File

@@ -19,6 +19,7 @@ import messages from './i18n';
import {
ComponentPicker,
CreateLibrary,
CreateLegacyLibrary,
LibraryLayout,
PreviewChangesEmbed,
} from './library-authoring';
@@ -67,6 +68,7 @@ const App = () => {
<Route path="/libraries" element={<StudioHome />} />
<Route path="/libraries-v1" element={<StudioHome />} />
<Route path="/libraries-v1/migrate" element={<LegacyLibMigrationPage />} />
<Route path="/libraries-v1/create" element={<CreateLegacyLibrary />} />
<Route path="/library/create" element={<CreateLibrary />} />
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
<Route

View File

@@ -0,0 +1,202 @@
import React from 'react';
import type MockAdapter from 'axios-mock-adapter';
import userEvent from '@testing-library/user-event';
import {
initializeMocks,
render,
screen,
waitFor,
} from '@src/testUtils';
import studioHomeMock from '@src/studio-home/__mocks__/studioHomeMock';
import { getStudioHomeApiUrl } from '@src/studio-home/data/api';
import { getApiWaffleFlagsUrl } from '@src/data/api';
import { CreateLegacyLibrary } from '.';
import { getContentLibraryV1CreateApiUrl } from './data/api';
const mockNavigate = jest.fn();
const realWindowLocationAssign = window.location.assign;
let axiosMock: MockAdapter;
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useNavigate: () => mockNavigate,
}));
jest.mock('@src/generic/data/apiHooks', () => ({
...jest.requireActual('@src/generic/data/apiHooks'),
useOrganizationListData: () => ({
data: ['org1', 'org2', 'org3', 'org4', 'org5'],
isLoading: false,
}),
}));
describe('<CreateLegacyLibrary />', () => {
beforeEach(() => {
axiosMock = initializeMocks().axiosMock;
axiosMock
.onGet(getApiWaffleFlagsUrl(undefined))
.reply(200, {});
Object.defineProperty(window, 'location', {
value: { assign: jest.fn() },
});
});
afterEach(() => {
jest.clearAllMocks();
axiosMock.restore();
window.location.assign = realWindowLocationAssign;
});
test('call api data with correct data', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV1CreateApiUrl()).reply(200, {
id: 'library-id',
url: '/library/library-id',
});
render(<CreateLegacyLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.click(titleInput);
await user.type(titleInput, 'Test Library Name');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
await user.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
'{"display_name":"Test Library Name","org":"org1","number":"test_library_slug"}',
);
expect(window.location.assign).toHaveBeenCalledWith('http://localhost:18010/library/library-id');
});
});
test('cannot create new org unless allowed', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV1CreateApiUrl()).reply(200, {
id: 'library-id',
url: '/library/library-id',
});
render(<CreateLegacyLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.click(titleInput);
await user.type(titleInput, 'Test Library Name');
// We cannot create a new org, and so we're restricted to the allowed list
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
await user.click(orgOptions);
expect(screen.getByText('org1')).toBeInTheDocument();
['org2', 'org3', 'org4', 'org5'].forEach((org) => expect(screen.queryByText(org)).not.toBeInTheDocument());
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'NewOrg');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
await user.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(0);
});
expect(await screen.findByText('Required field.')).toBeInTheDocument();
});
test('can create new org if allowed', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, {
...studioHomeMock,
allow_to_create_new_org: true,
});
axiosMock.onPost(getContentLibraryV1CreateApiUrl()).reply(200, {
id: 'library-id',
url: '/library/library-id',
});
render(<CreateLegacyLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.click(titleInput);
await user.type(titleInput, 'Test Library Name');
// We can create a new org, so we're also allowed to use any existing org
const orgOptions = screen.getByTestId('autosuggest-iconbutton');
await user.click(orgOptions);
['org1', 'org2', 'org3', 'org4', 'org5'].forEach((org) => expect(screen.queryByText(org)).toBeInTheDocument());
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'NewOrg');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
await user.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
'{"display_name":"Test Library Name","org":"NewOrg","number":"test_library_slug"}',
);
expect(window.location.assign).toHaveBeenCalledWith('http://localhost:18010/library/library-id');
});
});
test('show api error', async () => {
const user = userEvent.setup();
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
axiosMock.onPost(getContentLibraryV1CreateApiUrl()).reply(400, {
field: 'Error message',
});
render(<CreateLegacyLibrary />);
const titleInput = await screen.findByRole('textbox', { name: /library name/i });
await user.click(titleInput);
await user.type(titleInput, 'Test Library Name');
const orgInput = await screen.findByRole('combobox', { name: /organization/i });
await user.click(orgInput);
await user.type(orgInput, 'org1');
await user.tab();
const slugInput = await screen.findByRole('textbox', { name: /library id/i });
await user.click(slugInput);
await user.type(slugInput, 'test_library_slug');
await user.click(await screen.findByRole('button', { name: /create/i }));
await waitFor(async () => {
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(
'{"display_name":"Test Library Name","org":"org1","number":"test_library_slug"}',
);
expect(mockNavigate).not.toHaveBeenCalled();
});
await screen.findByText('Request failed with status code 400');
});
test('cancel creating library navigates to libraries page', async () => {
const user = userEvent.setup();
render(<CreateLegacyLibrary />);
await user.click(await screen.findByRole('button', { name: /cancel/i }));
await waitFor(() => {
expect(mockNavigate).toHaveBeenCalledWith('/libraries-v1');
});
});
});

View File

@@ -0,0 +1,206 @@
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
Container,
Form,
Button,
StatefulButton,
ActionRow,
} from '@openedx/paragon';
import { Formik } from 'formik';
import { useNavigate } from 'react-router-dom';
import * as Yup from 'yup';
import classNames from 'classnames';
import { REGEX_RULES } from '@src/constants';
import { useOrganizationListData } from '@src/generic/data/apiHooks';
import { useStudioHome } from '@src/studio-home/hooks';
import Header from '@src/header';
import SubHeader from '@src/generic/sub-header/SubHeader';
import FormikControl from '@src/generic/FormikControl';
import FormikErrorFeedback from '@src/generic/FormikErrorFeedback';
import AlertError from '@src/generic/alert-error';
import messages from '@src/library-authoring/create-library/messages';
import type { LibraryV1Data } from '@src/studio-home/data/api';
import legacyMessages from './messages';
import { useCreateLibraryV1 } from './data/apiHooks';
/**
* Renders the form and logic to create a new library.
*
* Use `showInModal` to render this component in a way that can be
* used in a modal. Currently this component is used in a modal in the
* legacy libraries migration flow.
*/
export const CreateLegacyLibrary = ({
showInModal = false,
handleCancel,
handlePostCreate,
}: {
showInModal?: boolean,
handleCancel?: () => void,
handlePostCreate?: (library: LibraryV1Data) => void,
}) => {
const intl = useIntl();
const navigate = useNavigate();
const { noSpaceRule, specialCharsRule } = REGEX_RULES;
const validSlugIdRegex = /^[a-zA-Z\d]+(?:[\w-]*[a-zA-Z\d]+)*$/;
const {
mutate,
data,
isPending,
isError,
error,
} = useCreateLibraryV1();
const {
data: allOrganizations,
isLoading: isOrganizationListLoading,
} = useOrganizationListData();
const {
studioHomeData: {
allowedOrganizationsForLibraries,
allowToCreateNewOrg,
},
} = useStudioHome();
const organizations = (
allowToCreateNewOrg
? allOrganizations
: allowedOrganizationsForLibraries
) || [];
const handleOnClickCancel = () => {
if (handleCancel) {
handleCancel();
} else {
navigate('/libraries-v1');
}
};
if (data) {
if (handlePostCreate) {
handlePostCreate(data);
} else {
window.location.assign(`${getConfig().STUDIO_BASE_URL}${data.url}`);
}
}
return (
<>
{!showInModal && (<Header isHiddenMainMenu />)}
<Container size="xl" className="p-4 mt-3">
{!showInModal && (
<SubHeader
title={intl.formatMessage(legacyMessages.createLibrary)}
/>
)}
<Formik
initialValues={{
displayName: '',
org: '',
number: '',
}}
validationSchema={
Yup.object().shape({
displayName: Yup.string()
.required(intl.formatMessage(messages.requiredFieldError)),
org: Yup.string()
.required(intl.formatMessage(messages.requiredFieldError))
.matches(
specialCharsRule,
intl.formatMessage(messages.disallowedCharsError),
)
.matches(noSpaceRule, intl.formatMessage(messages.noSpaceError)),
number: Yup.string()
.required(intl.formatMessage(messages.requiredFieldError))
.matches(
validSlugIdRegex,
intl.formatMessage(messages.invalidSlugError),
),
})
}
onSubmit={(values) => mutate(values)}
>
{(formikProps) => (
<Form onSubmit={formikProps.handleSubmit}>
<FormikControl
name="displayName"
label={<Form.Label>{intl.formatMessage(legacyMessages.titleLabel)}</Form.Label>}
value={formikProps.values.displayName}
placeholder={intl.formatMessage(messages.titlePlaceholder)}
help={intl.formatMessage(messages.titleHelp)}
className=""
controlClasses="pb-2"
/>
<Form.Group>
<Form.Label>{intl.formatMessage(messages.orgLabel)}</Form.Label>
<Form.Autosuggest
name="org"
isLoading={isOrganizationListLoading}
onChange={(event) => formikProps.setFieldValue(
'org',
allowToCreateNewOrg
? (event.selectionId || event.userProvidedText)
: event.selectionId,
)}
placeholder={intl.formatMessage(messages.orgPlaceholder)}
>
{organizations.map((org) => (
<Form.AutosuggestOption key={org} id={org}>{org}</Form.AutosuggestOption>
))}
</Form.Autosuggest>
<FormikErrorFeedback name="org">
<Form.Text>{intl.formatMessage(messages.orgHelp)}</Form.Text>
</FormikErrorFeedback>
</Form.Group>
<FormikControl
name="number"
label={<Form.Label>{intl.formatMessage(messages.slugLabel)}</Form.Label>}
value={formikProps.values.number}
placeholder={intl.formatMessage(messages.slugPlaceholder)}
help={intl.formatMessage(messages.slugHelp)}
className=""
controlClasses="pb-2"
/>
<ActionRow className={
classNames(
{
'justify-content-start': !showInModal,
'justify-content-end': showInModal,
},
)
}
>
<Button
variant="outline-primary"
onClick={handleOnClickCancel}
>
{intl.formatMessage(messages.cancelCreateLibraryButton)}
</Button>
<StatefulButton
type="submit"
variant="primary"
className="action btn-primary"
state={isPending ? 'disabled' : 'enabled'}
disabledStates={['disabled']}
labels={{
enabled: intl.formatMessage(legacyMessages.createLibraryButton),
disabled: intl.formatMessage(legacyMessages.createLibraryButtonPending),
}}
/>
</ActionRow>
</Form>
)}
</Formik>
{isError && (<AlertError error={error} />)}
</Container>
{!showInModal && (<StudioFooterSlot />)}
</>
);
};

View File

@@ -0,0 +1,27 @@
import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import type { LibraryV1Data } from '@src/studio-home/data/api';
/**
* Get the URL for creating a new library.
*/
export const getContentLibraryV1CreateApiUrl = () => `${getConfig().STUDIO_BASE_URL}/library/`;
export interface CreateContentLibraryV1Args {
displayName: string,
org: string,
number: string,
}
/**
* Create a new library
*/
export async function createLibraryV1(data: CreateContentLibraryV1Args): Promise<LibraryV1Data> {
const client = getAuthenticatedHttpClient();
const url = getContentLibraryV1CreateApiUrl();
const { data: newLibrary } = await client.post(url, { ...snakeCaseObject(data) });
return camelCaseObject(newLibrary);
}

View File

@@ -0,0 +1,21 @@
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { studioHomeQueryKeys } from '@src/studio-home/data/apiHooks';
import { createLibraryV1 } from './api';
/**
* Hook that provides a "mutation" that can be used to create a new content library.
*/
export const useCreateLibraryV1 = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createLibraryV1,
onSettled: () => {
queryClient.invalidateQueries({ queryKey: studioHomeQueryKeys.librariesV1() });
},
});
};

View File

@@ -0,0 +1 @@
export { CreateLegacyLibrary } from './CreateLegacyLibrary';

View File

@@ -0,0 +1,26 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
createLibrary: {
id: 'course-authoring.library-authoring.create-legacy-library',
defaultMessage: 'Create new legacy library',
description: 'Header for the create legacy library form',
},
titleLabel: {
id: 'course-authoring.library-authoring.create-legacy-library.form.title.label',
defaultMessage: 'Legacy library name',
description: 'Label for the title field when creating a legacy library.',
},
createLibraryButton: {
id: 'course-authoring.library-authoring.create-legacy-library.form.create-library.button',
defaultMessage: 'Create legacy library',
description: 'Button text for creating a new legacy library.',
},
createLibraryButtonPending: {
id: 'course-authoring.library-authoring.create-legacy-library.form.create-library.button.pending',
defaultMessage: 'Creating legacy library..',
description: 'Button text while the legacy library is being created.',
},
});
export default messages;

View File

@@ -2,5 +2,6 @@ export { default as LibraryLayout } from './LibraryLayout';
export { ComponentPicker } from './component-picker';
export { type SelectedComponent } from './common/context/ComponentPickerContext';
export { CreateLibrary, CreateLibraryModal } from './create-library';
export { CreateLegacyLibrary } from './create-legacy-library';
export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks';
export { default as PreviewChangesEmbed } from './legacy-integration/PreviewChangesEmbed';

View File

@@ -113,22 +113,18 @@ describe('<StudioHome />', () => {
});
describe('render new library button', () => {
it('should navigate to home_library when libraries-v2 disabled', async () => {
it('should navigate to legacy library creation when libraries-v2 disabled', async () => {
mockUseSelector.mockReturnValue({
...studioHomeMock,
courseCreatorStatus: COURSE_CREATOR_STATES.granted,
librariesV2Enabled: false,
});
const studioBaseUrl = 'http://localhost:18010';
render(<StudioHome />, { path: '/home' });
await waitFor(() => {
const createNewLibraryButton = screen.getByRole('button', { name: 'New library' });
const mockWindowOpen = jest.spyOn(window, 'open');
fireEvent.click(createNewLibraryButton);
expect(mockWindowOpen).toHaveBeenCalledWith(`${studioBaseUrl}/home_library`);
mockWindowOpen.mockRestore();
expect(mockNavigate).toHaveBeenCalledWith('/libraries-v1/create');
});
});

View File

@@ -10,7 +10,6 @@ import {
import { Add as AddIcon, Error } from '@openedx/paragon/icons';
import { useIntl } from '@edx/frontend-platform/i18n';
import { StudioFooterSlot } from '@edx/frontend-component-footer';
import { getConfig } from '@edx/frontend-platform';
import { useLocation, useNavigate } from 'react-router-dom';
import Loading from '../generic/Loading';
@@ -91,8 +90,7 @@ const StudioHome = () => {
if (showV2LibraryURL) {
navigate('/library/create');
} else {
// Studio home library for legacy libraries
window.open(`${getConfig().STUDIO_BASE_URL}/home_library`);
navigate('/libraries-v1/create');
}
};