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) => ( +
+ {intl.formatMessage(legacyMessages.titleLabel)}} + value={formikProps.values.displayName} + placeholder={intl.formatMessage(messages.titlePlaceholder)} + help={intl.formatMessage(messages.titleHelp)} + className="" + controlClasses="pb-2" + /> + + {intl.formatMessage(messages.orgLabel)} + formikProps.setFieldValue( + 'org', + allowToCreateNewOrg + ? (event.selectionId || event.userProvidedText) + : event.selectionId, + )} + placeholder={intl.formatMessage(messages.orgPlaceholder)} + > + {organizations.map((org) => ( + {org} + ))} + + + {intl.formatMessage(messages.orgHelp)} + + + {intl.formatMessage(messages.slugLabel)}} + value={formikProps.values.number} + placeholder={intl.formatMessage(messages.slugPlaceholder)} + help={intl.formatMessage(messages.slugHelp)} + className="" + controlClasses="pb-2" + /> + + + + + + )} +
+ {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'); } };