diff --git a/src/index.jsx b/src/index.jsx
index 43e2ab210..bbd95e5d7 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -19,6 +19,7 @@ import messages from './i18n';
import {
ComponentPicker,
CreateLibrary,
+ CreateLegacyLibrary,
LibraryLayout,
PreviewChangesEmbed,
} from './library-authoring';
@@ -67,6 +68,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
({
+ ...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('', () => {
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ 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();
+
+ await user.click(await screen.findByRole('button', { name: /cancel/i }));
+ await waitFor(() => {
+ expect(mockNavigate).toHaveBeenCalledWith('/libraries-v1');
+ });
+ });
+});
diff --git a/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx b/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx
new file mode 100644
index 000000000..4a60d7e35
--- /dev/null
+++ b/src/library-authoring/create-legacy-library/CreateLegacyLibrary.tsx
@@ -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 && ()}
+
+ {!showInModal && (
+
+ )}
+ mutate(values)}
+ >
+ {(formikProps) => (
+
+ )}
+
+ {isError && ()}
+
+ {!showInModal && ()}
+ >
+ );
+};
diff --git a/src/library-authoring/create-legacy-library/data/api.ts b/src/library-authoring/create-legacy-library/data/api.ts
new file mode 100644
index 000000000..8652eb8a6
--- /dev/null
+++ b/src/library-authoring/create-legacy-library/data/api.ts
@@ -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 {
+ const client = getAuthenticatedHttpClient();
+ const url = getContentLibraryV1CreateApiUrl();
+
+ const { data: newLibrary } = await client.post(url, { ...snakeCaseObject(data) });
+
+ return camelCaseObject(newLibrary);
+}
diff --git a/src/library-authoring/create-legacy-library/data/apiHooks.ts b/src/library-authoring/create-legacy-library/data/apiHooks.ts
new file mode 100644
index 000000000..1d10760b6
--- /dev/null
+++ b/src/library-authoring/create-legacy-library/data/apiHooks.ts
@@ -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() });
+ },
+ });
+};
diff --git a/src/library-authoring/create-legacy-library/index.ts b/src/library-authoring/create-legacy-library/index.ts
new file mode 100644
index 000000000..b31a5c424
--- /dev/null
+++ b/src/library-authoring/create-legacy-library/index.ts
@@ -0,0 +1 @@
+export { CreateLegacyLibrary } from './CreateLegacyLibrary';
diff --git a/src/library-authoring/create-legacy-library/messages.ts b/src/library-authoring/create-legacy-library/messages.ts
new file mode 100644
index 000000000..d162e90d9
--- /dev/null
+++ b/src/library-authoring/create-legacy-library/messages.ts
@@ -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;
diff --git a/src/library-authoring/index.tsx b/src/library-authoring/index.tsx
index fb1914f6b..8b3419ff4 100644
--- a/src/library-authoring/index.tsx
+++ b/src/library-authoring/index.tsx
@@ -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';
diff --git a/src/studio-home/StudioHome.test.tsx b/src/studio-home/StudioHome.test.tsx
index 40b104d9f..b6dbc15b3 100644
--- a/src/studio-home/StudioHome.test.tsx
+++ b/src/studio-home/StudioHome.test.tsx
@@ -113,22 +113,18 @@ describe('', () => {
});
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(, { 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');
});
});
diff --git a/src/studio-home/StudioHome.tsx b/src/studio-home/StudioHome.tsx
index ea7a2d5ea..fd82a5ba6 100644
--- a/src/studio-home/StudioHome.tsx
+++ b/src/studio-home/StudioHome.tsx
@@ -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');
}
};