From e0870019056fc8e6273b68bf58770a916fc09a35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=B4mulo=20Penido?= Date: Fri, 12 Jul 2024 14:54:37 +0200 Subject: [PATCH] feat: create library (v2) form (#1116) --- src/constants.js | 5 + src/generic/alert-error/AlertError.test.tsx | 40 +++++ src/generic/alert-error/index.tsx | 14 ++ src/generic/create-or-rerun-course/hooks.jsx | 5 +- src/generic/data/apiHooks.js | 2 +- src/library-authoring/CreateLibrary.tsx | 27 ---- .../LibraryAuthoringPage.test.tsx | 1 + .../LibraryAuthoringPage.tsx | 2 +- src/library-authoring/LibraryComponents.tsx | 2 +- src/library-authoring/LibraryHome.tsx | 2 +- .../__mocks__/contentLibrariesListV2.js} | 0 .../create-library/CreateLibrary.test.tsx | 131 +++++++++++++++++ .../create-library/CreateLibrary.tsx | 138 ++++++++++++++++++ .../create-library/data/api.ts | 30 ++++ .../create-library/data/apiHooks.ts | 22 +++ src/library-authoring/create-library/index.ts | 2 + .../create-library/messages.ts | 88 +++++++++++ src/library-authoring/data/api.ts | 50 ++++++- .../data/{apiHook.ts => apiHooks.ts} | 28 +++- src/library-authoring/index.ts | 3 +- src/library-authoring/messages.ts | 10 -- src/library/data/api.ts | 66 --------- src/studio-home/__mocks__/index.js | 2 +- src/studio-home/data/api.test.js | 12 -- src/studio-home/data/apiHooks.ts | 16 -- .../tabs-section/TabsSection.test.jsx | 53 ++----- .../tabs-section/libraries-v2-tab/index.tsx | 4 +- 27 files changed, 570 insertions(+), 185 deletions(-) create mode 100644 src/generic/alert-error/AlertError.test.tsx create mode 100644 src/generic/alert-error/index.tsx delete mode 100644 src/library-authoring/CreateLibrary.tsx rename src/{studio-home/__mocks__/listStudioHomeV2LibrariesMock.js => library-authoring/__mocks__/contentLibrariesListV2.js} (100%) create mode 100644 src/library-authoring/create-library/CreateLibrary.test.tsx create mode 100644 src/library-authoring/create-library/CreateLibrary.tsx create mode 100644 src/library-authoring/create-library/data/api.ts create mode 100644 src/library-authoring/create-library/data/apiHooks.ts create mode 100644 src/library-authoring/create-library/index.ts create mode 100644 src/library-authoring/create-library/messages.ts rename src/library-authoring/data/{apiHook.ts => apiHooks.ts} (61%) delete mode 100644 src/library/data/api.ts delete mode 100644 src/studio-home/data/apiHooks.ts diff --git a/src/constants.js b/src/constants.js index a641c8add8..f9e84c19de 100644 --- a/src/constants.js +++ b/src/constants.js @@ -69,3 +69,8 @@ export const CLIPBOARD_STATUS = { }; export const STRUCTURAL_XBLOCK_TYPES = ['vertical', 'sequential', 'chapter', 'course']; + +export const REGEX_RULES = { + specialCharsRule: /^[a-zA-Z0-9_\-.'*~\s]+$/, + noSpaceRule: /^\S*$/, +}; diff --git a/src/generic/alert-error/AlertError.test.tsx b/src/generic/alert-error/AlertError.test.tsx new file mode 100644 index 0000000000..6cfee70db0 --- /dev/null +++ b/src/generic/alert-error/AlertError.test.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; + +import AlertError from '.'; + +const RootWrapper = ({ error }: { error: unknown }) => ( + + + +); + +describe('', () => { + test('render using a string', () => { + const error = 'This is a string error message'; + const { getByText } = render(); + expect(getByText('This is a string error message')).toBeInTheDocument(); + }); + + test('render using an error', () => { + const error = new Error('This is an error message'); + const { getByText } = render(); + expect(getByText('This is an error message')).toBeInTheDocument(); + }); + + test('render using an error with response', () => { + const error = { + message: 'This is an error message', + response: { + data: { + message: 'This is a response body', + }, + }, + }; + const { getByText } = render(); + screen.logTestingPlaygroundURL(); + expect(getByText(/this is an error message/i)).toBeInTheDocument(); + expect(getByText(/\{"message":"this is a response body"\}/i)).toBeInTheDocument(); + }); +}); diff --git a/src/generic/alert-error/index.tsx b/src/generic/alert-error/index.tsx new file mode 100644 index 0000000000..a0612fc473 --- /dev/null +++ b/src/generic/alert-error/index.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { + Alert, +} from '@openedx/paragon'; + +const AlertError: React.FC<{ error: unknown }> = ({ error }) => ( + + {error instanceof Object && 'message' in error ? error.message : String(error)} +
+ {error instanceof Object && (error as any).response?.data && JSON.stringify((error as any).response?.data)} +
+); + +export default AlertError; diff --git a/src/generic/create-or-rerun-course/hooks.jsx b/src/generic/create-or-rerun-course/hooks.jsx index 5bb744aa4a..a5c5196a80 100644 --- a/src/generic/create-or-rerun-course/hooks.jsx +++ b/src/generic/create-or-rerun-course/hooks.jsx @@ -5,6 +5,7 @@ import { useFormik } from 'formik'; import * as Yup from 'yup'; import { useNavigate } from 'react-router-dom'; +import { REGEX_RULES } from '../../constants'; import { RequestStatus } from '../../data/constants'; import { getStudioHomeData } from '../../studio-home/data/selectors'; import { @@ -32,8 +33,8 @@ const useCreateOrRerunCourse = (initialValues) => { const [isFormFilled, setFormFilled] = useState(false); const [showErrorBanner, setShowErrorBanner] = useState(false); const organizations = allowToCreateNewOrg ? allOrganizations : allowedOrganizations; - const specialCharsRule = /^[a-zA-Z0-9_\-.'*~\s]+$/; - const noSpaceRule = /^\S*$/; + + const { specialCharsRule, noSpaceRule } = REGEX_RULES; const validationSchema = Yup.object().shape({ displayName: Yup.string().required( intl.formatMessage(messages.requiredFieldError), diff --git a/src/generic/data/apiHooks.js b/src/generic/data/apiHooks.js index e15ebc12ad..7fec094dd6 100644 --- a/src/generic/data/apiHooks.js +++ b/src/generic/data/apiHooks.js @@ -8,7 +8,7 @@ import { getOrganizations, getTagsCount } from './api'; export const useOrganizationListData = () => ( useQuery({ queryKey: ['organizationList'], - queryFn: () => getOrganizations(), + queryFn: getOrganizations, }) ); diff --git a/src/library-authoring/CreateLibrary.tsx b/src/library-authoring/CreateLibrary.tsx deleted file mode 100644 index 227f14dbe5..0000000000 --- a/src/library-authoring/CreateLibrary.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import React from 'react'; -import { FormattedMessage } from '@edx/frontend-platform/i18n'; -import { Container } from '@openedx/paragon'; - -import Header from '../header'; -import SubHeader from '../generic/sub-header/SubHeader'; - -import messages from './messages'; - -/* istanbul ignore next This is only a placeholder component */ -const CreateLibrary = () => ( - <> -
- - } - /> -
- -
-
- -); - -export default CreateLibrary; diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index 958e3e4486..26073ee238 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -62,6 +62,7 @@ const libraryData: ContentLibrary = { hasUnpublishedChanges: true, hasUnpublishedDeletes: false, license: '', + canEditLibrary: false, }; const RootWrapper = () => ( diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 7f16432778..f369025a32 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -16,7 +16,7 @@ import NotFoundAlert from '../generic/NotFoundAlert'; import LibraryComponents from './LibraryComponents'; import LibraryCollections from './LibraryCollections'; import LibraryHome from './LibraryHome'; -import { useContentLibrary } from './data/apiHook'; +import { useContentLibrary } from './data/apiHooks'; import messages from './messages'; enum TabList { diff --git a/src/library-authoring/LibraryComponents.tsx b/src/library-authoring/LibraryComponents.tsx index fee8cb3502..a8d2cd281b 100644 --- a/src/library-authoring/LibraryComponents.tsx +++ b/src/library-authoring/LibraryComponents.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { FormattedMessage } from '@edx/frontend-platform/i18n'; import { NoComponents, NoSearchResults } from './EmptyStates'; -import { useLibraryComponentCount } from './data/apiHook'; +import { useLibraryComponentCount } from './data/apiHooks'; import messages from './messages'; type LibraryComponentsProps = { diff --git a/src/library-authoring/LibraryHome.tsx b/src/library-authoring/LibraryHome.tsx index 273d36fca4..1a79c05cf0 100644 --- a/src/library-authoring/LibraryHome.tsx +++ b/src/library-authoring/LibraryHome.tsx @@ -7,7 +7,7 @@ import { import { NoComponents, NoSearchResults } from './EmptyStates'; import LibraryCollections from './LibraryCollections'; import LibraryComponents from './LibraryComponents'; -import { useLibraryComponentCount } from './data/apiHook'; +import { useLibraryComponentCount } from './data/apiHooks'; import messages from './messages'; const Section = ({ title, children } : { title: string, children: React.ReactNode }) => ( diff --git a/src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js b/src/library-authoring/__mocks__/contentLibrariesListV2.js similarity index 100% rename from src/studio-home/__mocks__/listStudioHomeV2LibrariesMock.js rename to src/library-authoring/__mocks__/contentLibrariesListV2.js diff --git a/src/library-authoring/create-library/CreateLibrary.test.tsx b/src/library-authoring/create-library/CreateLibrary.test.tsx new file mode 100644 index 0000000000..b9a63ec02d --- /dev/null +++ b/src/library-authoring/create-library/CreateLibrary.test.tsx @@ -0,0 +1,131 @@ +import React from 'react'; +import MockAdapter from 'axios-mock-adapter'; +import { initializeMockApp } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import { AppProvider } from '@edx/frontend-platform/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import initializeStore from '../../store'; +import CreateLibrary from './CreateLibrary'; +import { getContentLibraryV2CreateApiUrl } from './data/api'; + +let store; +const mockNavigate = jest.fn(); +let axiosMock: MockAdapter; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useNavigate: () => mockNavigate, +})); + +jest.mock('../../generic/data/apiHooks', () => ({ + ...jest.requireActual('../../generic/data/apiHooks'), + useOrganizationListData: () => ({ + data: ['org1', 'org2', 'org3', 'org4', 'org5'], + isLoading: false, + }), +})); + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); + +const RootWrapper = () => ( + + + + + + + +); + +describe('', () => { + beforeEach(() => { + initializeMockApp({ + authenticatedUser: { + userId: 3, + username: 'abc123', + administrator: true, + roles: [], + }, + }); + store = initializeStore(); + + axiosMock = new MockAdapter(getAuthenticatedHttpClient()); + }); + + afterEach(() => { + jest.clearAllMocks(); + axiosMock.restore(); + queryClient.clear(); + }); + + test('call api data with correct data', async () => { + axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(200, { + id: 'library-id', + }); + + const { getByRole } = render(); + + const titleInput = getByRole('textbox', { name: /library name/i }); + userEvent.click(titleInput); + userEvent.type(titleInput, 'Test Library Name'); + + const orgInput = getByRole('combobox', { name: /organization/i }); + userEvent.click(orgInput); + userEvent.type(orgInput, 'org1'); + userEvent.tab(); + + const slugInput = getByRole('textbox', { name: /library id/i }); + userEvent.click(slugInput); + userEvent.type(slugInput, 'test_library_slug'); + + fireEvent.click(getByRole('button', { name: /create/i })); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe( + '{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}', + ); + expect(mockNavigate).toHaveBeenCalledWith('/library/library-id'); + }); + }); + + test('show api error', async () => { + axiosMock.onPost(getContentLibraryV2CreateApiUrl()).reply(400, { + field: 'Error message', + }); + const { getByRole, getByTestId } = render(); + + const titleInput = getByRole('textbox', { name: /library name/i }); + userEvent.click(titleInput); + userEvent.type(titleInput, 'Test Library Name'); + + const orgInput = getByTestId('autosuggest-textbox-input'); + userEvent.click(orgInput); + userEvent.type(orgInput, 'org1'); + userEvent.tab(); + + const slugInput = getByRole('textbox', { name: /library id/i }); + userEvent.click(slugInput); + userEvent.type(slugInput, 'test_library_slug'); + + fireEvent.click(getByRole('button', { name: /create/i })); + await waitFor(() => { + expect(axiosMock.history.post.length).toBe(1); + expect(axiosMock.history.post[0].data).toBe( + '{"description":"","title":"Test Library Name","org":"org1","slug":"test_library_slug"}', + ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(getByRole('alert')).toHaveTextContent('Request failed with status code 400'); + expect(getByRole('alert')).toHaveTextContent('{"field":"Error message"}'); + }); + }); +}); diff --git a/src/library-authoring/create-library/CreateLibrary.tsx b/src/library-authoring/create-library/CreateLibrary.tsx new file mode 100644 index 0000000000..f705c73a4d --- /dev/null +++ b/src/library-authoring/create-library/CreateLibrary.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { StudioFooter } from '@edx/frontend-component-footer'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { + Container, + Form, + StatefulButton, +} from '@openedx/paragon'; +import { Formik } from 'formik'; +import { useNavigate } from 'react-router-dom'; +import * as Yup from 'yup'; + +import { REGEX_RULES } from '../../constants'; +import Header from '../../header'; +import FormikControl from '../../generic/FormikControl'; +import FormikErrorFeedback from '../../generic/FormikErrorFeedback'; +import AlertError from '../../generic/alert-error'; +import { useOrganizationListData } from '../../generic/data/apiHooks'; +import SubHeader from '../../generic/sub-header/SubHeader'; +import { useCreateLibraryV2 } from './data/apiHooks'; +import messages from './messages'; + +const CreateLibrary = () => { + 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, + isLoading, + isError, + error, + } = useCreateLibraryV2(); + + const { + data: organizationListData, + isLoading: isOrganizationListLoading, + } = useOrganizationListData(); + + if (data) { + navigate(`/library/${data.id}`); + } + + return ( + <> +
+ + + mutate(values)} + > + {(formikProps) => ( +
+ {intl.formatMessage(messages.titleLabel)}} + value={formikProps.values.title} + placeholder={intl.formatMessage(messages.titlePlaceholder)} + help={intl.formatMessage(messages.titleHelp)} + className="" + controlClasses="pb-2" + /> + + {intl.formatMessage(messages.orgLabel)} + formikProps.setFieldValue('org', event.selectionId)} + placeholder={intl.formatMessage(messages.orgPlaceholder)} + > + {organizationListData ? organizationListData.map((org) => ( + {org} + )) : []} + + + {intl.formatMessage(messages.orgHelp)} + + + {intl.formatMessage(messages.slugLabel)}} + value={formikProps.values.slug} + placeholder={intl.formatMessage(messages.slugPlaceholder)} + help={intl.formatMessage(messages.slugHelp)} + className="" + controlClasses="pb-2" + /> + + + )} +
+ {isError && ()} +
+ + + ); +}; + +export default CreateLibrary; diff --git a/src/library-authoring/create-library/data/api.ts b/src/library-authoring/create-library/data/api.ts new file mode 100644 index 0000000000..b529de5e9d --- /dev/null +++ b/src/library-authoring/create-library/data/api.ts @@ -0,0 +1,30 @@ +import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; + +import type { ContentLibrary } from '../../data/api'; + +const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + +/** + * Get the URL for creating a new library. + */ +export const getContentLibraryV2CreateApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`; + +export interface CreateContentLibraryArgs { + title: string, + org: string, + slug: string, +} + +/** + * Create a new library + */ +export async function createLibraryV2(data: CreateContentLibraryArgs): Promise { + const client = getAuthenticatedHttpClient(); + const url = getContentLibraryV2CreateApiUrl(); + + // Description field cannot be null, but we don't have a input in the form for it + const { data: newLibrary } = await client.post(url, { description: '', ...data }); + + return camelCaseObject(newLibrary); +} diff --git a/src/library-authoring/create-library/data/apiHooks.ts b/src/library-authoring/create-library/data/apiHooks.ts new file mode 100644 index 0000000000..3a58771b98 --- /dev/null +++ b/src/library-authoring/create-library/data/apiHooks.ts @@ -0,0 +1,22 @@ +import { + useMutation, + useQueryClient, +} from '@tanstack/react-query'; + +import { createLibraryV2 } from './api'; +import { libraryAuthoringQueryKeys } from '../../data/apiHooks'; + +/** + * Hook that provides a "mutation" that can be used to create a new content library. + */ +// eslint-disable-next-line import/prefer-default-export +export const useCreateLibraryV2 = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: createLibraryV2, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: libraryAuthoringQueryKeys.contentLibraryList() }); + }, + }); +}; diff --git a/src/library-authoring/create-library/index.ts b/src/library-authoring/create-library/index.ts new file mode 100644 index 0000000000..dedcc16dcb --- /dev/null +++ b/src/library-authoring/create-library/index.ts @@ -0,0 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export +export { default as CreateLibrary } from './CreateLibrary'; diff --git a/src/library-authoring/create-library/messages.ts b/src/library-authoring/create-library/messages.ts new file mode 100644 index 0000000000..295808b56a --- /dev/null +++ b/src/library-authoring/create-library/messages.ts @@ -0,0 +1,88 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + createLibrary: { + id: 'course-authoring.library-authoring.create-library', + defaultMessage: 'Create new library', + description: 'Header for the create library form', + }, + titleLabel: { + id: 'course-authoring.library-authoring.create-library.form.title.label', + defaultMessage: 'Library name', + description: 'Label for the title field.', + }, + titlePlaceholder: { + id: 'course-authoring.library-authoring.create-library.form.title.placeholder', + defaultMessage: 'e.g. Computer Science Problems', + description: 'Placeholder text for the title field.', + }, + titleHelp: { + id: 'course-authoring.library-authoring.create-library.form.title.help', + defaultMessage: 'The name for your library', + description: 'Help text for the title field.', + }, + orgLabel: { + id: 'course-authoring.library-authoring.create-library.form.org.label', + defaultMessage: 'Organization', + description: 'Label for the organization field.', + }, + orgPlaceholder: { + id: 'course-authoring.library-authoring.create-library.form.org.placeholder', + defaultMessage: 'e.g. UniversityX or OrganizationX', + description: 'Placeholder text for the organization field.', + }, + orgHelp: { + id: 'course-authoring.library-authoring.create-library.form.org.help', + defaultMessage: 'The public organization name for your library. This cannot be changed.', + description: 'Help text for the organization field.', + }, + slugLabel: { + id: 'course-authoring.library-authoring.create-library.form.slug.label', + defaultMessage: 'Library ID', + description: 'Label for the slug field.', + }, + slugPlaceholder: { + id: 'course-authoring.library-authoring.create-library.form.slug.placeholder', + defaultMessage: 'e.g. CSPROB', + description: 'Placeholder text for the slug field.', + }, + slugHelp: { + id: 'course-authoring.library-authoring.create-library.form.slug.help', + defaultMessage: `The unique code that identifies this library. Note: This is + part of your library URL, so no spaces or special characters are allowed. + This cannot be changed.`, + description: 'Help text for the slug field.', + }, + invalidSlugError: { + id: 'course-authoring.library-authoring.create-library.form.invalid-slug.error', + defaultMessage: 'Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens.', + description: 'Text to display when slug id has invalid symbols.', + }, + requiredFieldError: { + id: 'course-authoring.library-authoring.create-library.form.required.error', + defaultMessage: 'Required field.', + description: 'Error message to display when a required field is missing.', + }, + disallowedCharsError: { + id: 'course-authoring.library-authoring.create-library.form.disallowed-chars.error', + defaultMessage: 'Please do not use any spaces or special characters in this field.', + description: 'Error message to display when a field contains disallowed characters.', + }, + noSpaceError: { + id: 'course-authoring.library-authoring.create-library.form.no-space.error', + defaultMessage: 'Please do not use any spaces in this field.', + description: 'Error message to display when a field contains spaces.', + }, + createLibraryButton: { + id: 'course-authoring.library-authoring.create-library.form.create-library.button', + defaultMessage: 'Create', + description: 'Button text for creating a new library.', + }, + createLibraryButtonPending: { + id: 'course-authoring.library-authoring.create-library.form.create-library.button.pending', + defaultMessage: 'Creating..', + description: 'Button text while the library is being created.', + }, +}); + +export default messages; diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts index 95126d8269..be3ec564f2 100644 --- a/src/library-authoring/data/api.ts +++ b/src/library-authoring/data/api.ts @@ -1,11 +1,13 @@ -import { camelCaseObject, getConfig } from '@edx/frontend-platform'; +import { camelCaseObject, getConfig, snakeCaseObject } from '@edx/frontend-platform'; import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; + /** * Get the URL for the content library API. */ export const getContentLibraryApiUrl = (libraryId: string) => `${getApiBaseUrl()}/api/libraries/v2/${libraryId}/`; +export const getContentLibraryV2ListApiUrl = () => `${getApiBaseUrl()}/api/libraries/v2/`; export interface ContentLibrary { id: string; @@ -23,6 +25,7 @@ export interface ContentLibrary { hasUnpublishedChanges: boolean; hasUnpublishedDeletes: boolean; license: string; + canEditLibrary: boolean; } /** @@ -36,3 +39,48 @@ export async function getContentLibrary(libraryId?: string): Promise { + // Set default params if not passed in + const customParamsDefaults = { + type: customParams.type || 'complex', + page: customParams.page || 1, + pageSize: customParams.pageSize || 50, + pagination: customParams.pagination !== undefined ? customParams.pagination : true, + order: customParams.order || 'title', + textSearch: customParams.search, + }; + const customParamsFormated = snakeCaseObject(customParamsDefaults); + const { data } = await getAuthenticatedHttpClient() + .get(getContentLibraryV2ListApiUrl(), { params: customParamsFormated }); + return camelCaseObject(data); +} diff --git a/src/library-authoring/data/apiHook.ts b/src/library-authoring/data/apiHooks.ts similarity index 61% rename from src/library-authoring/data/apiHook.ts rename to src/library-authoring/data/apiHooks.ts index 56a6791d26..4b887bb4a2 100644 --- a/src/library-authoring/data/apiHook.ts +++ b/src/library-authoring/data/apiHooks.ts @@ -3,14 +3,27 @@ import { useQuery } from '@tanstack/react-query'; import { MeiliSearch } from 'meilisearch'; import { useContentSearchConnection, useContentSearchResults } from '../../search-modal'; -import { getContentLibrary } from './api'; +import { type GetLibrariesV2CustomParams, getContentLibrary, getContentLibraryV2List } from './api'; + +export const libraryAuthoringQueryKeys = { + all: ['contentLibrary'], + /** + * Base key for data specific to a contentLibrary + */ + contentLibrary: (contentLibraryId?: string) => [...libraryAuthoringQueryKeys.all, contentLibraryId], + contentLibraryList: (customParams?: GetLibrariesV2CustomParams) => [ + ...libraryAuthoringQueryKeys.all, + 'list', + ...(customParams ? [customParams] : []), + ], +}; /** * Hook to fetch a content library by its ID. */ export const useContentLibrary = (libraryId?: string) => ( useQuery({ - queryKey: ['contentLibrary', libraryId], + queryKey: libraryAuthoringQueryKeys.contentLibrary(libraryId), queryFn: () => getContentLibrary(libraryId), }) ); @@ -46,3 +59,14 @@ export const useLibraryComponentCount = (libraryId: string, searchKeywords: stri collectionCount, }; }; + +/** + * Builds the query to fetch list of V2 Libraries + */ +export const useContentLibraryV2List = (customParams: GetLibrariesV2CustomParams) => ( + useQuery({ + queryKey: libraryAuthoringQueryKeys.contentLibraryList(customParams), + queryFn: () => getContentLibraryV2List(customParams), + keepPreviousData: true, + }) +); diff --git a/src/library-authoring/index.ts b/src/library-authoring/index.ts index 40da2db4af..817a857375 100644 --- a/src/library-authoring/index.ts +++ b/src/library-authoring/index.ts @@ -1,2 +1,3 @@ export { default as LibraryAuthoringPage } from './LibraryAuthoringPage'; -export { default as CreateLibrary } from './CreateLibrary'; +export { CreateLibrary } from './create-library'; +export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks'; diff --git a/src/library-authoring/messages.ts b/src/library-authoring/messages.ts index c63d66b3e4..0cc3217380 100644 --- a/src/library-authoring/messages.ts +++ b/src/library-authoring/messages.ts @@ -65,16 +65,6 @@ const messages = defineMessages({ defaultMessage: 'Recently modified components and collections will be displayed here.', description: 'Temp placeholder for the recent components container. This will be replaced with the actual list.', }, - createLibrary: { - id: 'course-authoring.library-authoring.create-library', - defaultMessage: 'Create library', - description: 'Header for the create library form', - }, - createLibraryTempPlaceholder: { - id: 'course-authoring.library-authoring.create-library-temp-placeholder', - defaultMessage: 'This is a placeholder for the create library form. This will be replaced with the actual form.', - description: 'Temp placeholder for the create library container. This will be replaced with the new library form.', - }, recentlyModifiedTitle: { id: 'course-authoring.library-authoring.recently-modified-title', defaultMessage: 'Recently Modified', diff --git a/src/library/data/api.ts b/src/library/data/api.ts deleted file mode 100644 index 2a31db309e..0000000000 --- a/src/library/data/api.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { camelCaseObject, snakeCaseObject, getConfig } from '@edx/frontend-platform'; -import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth'; - -export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL; - -export interface LibraryV2 { - id: string, - type: string, - org: string, - slug: string, - title: string, - description: string, - numBlocks: number, - version: number, - lastPublished: string | null, - allowLti: boolean, - allowPublicLearning: boolean, - allowPublicRead: boolean, - hasUnpublishedChanges: boolean, - hasUnpublishedDeletes: boolean, - license: string, -} - -export interface LibrariesV2Response { - next: string | null, - previous: string | null, - count: number, - numPages: number, - currentPage: number, - start: number, - results: LibraryV2[], -} - -/* Additional custom parameters for the API request. */ -export interface GetLibrariesV2CustomParams { - /* (optional) Library type, default `complex` */ - type?: string, - /* (optional) Page number of results */ - page?: number, - /* (optional) The number of results on each page, default `50` */ - pageSize?: number, - /* (optional) Whether pagination is supported, default `true` */ - pagination?: boolean, - /* (optional) Library field to order results by. Prefix with '-' for descending */ - order?: string, - /* (optional) Search query to filter v2 Libraries by */ - search?: string, -} - -/** - * Get's studio home v2 Libraries. - */ -export async function getStudioHomeLibrariesV2(customParams: GetLibrariesV2CustomParams): Promise { - // Set default params if not passed in - const customParamsDefaults = { - type: customParams.type || 'complex', - page: customParams.page || 1, - pageSize: customParams.pageSize || 50, - pagination: customParams.pagination !== undefined ? customParams.pagination : true, - order: customParams.order || 'title', - textSearch: customParams.search, - }; - const customParamsFormat = snakeCaseObject(customParamsDefaults); - const { data } = await getAuthenticatedHttpClient().get(`${getApiBaseUrl()}/api/libraries/v2/`, { params: customParamsFormat }); - return camelCaseObject(data); -} diff --git a/src/studio-home/__mocks__/index.js b/src/studio-home/__mocks__/index.js index af2a85b390..92461eb0bb 100644 --- a/src/studio-home/__mocks__/index.js +++ b/src/studio-home/__mocks__/index.js @@ -1,2 +1,2 @@ +// eslint-disable-next-line import/prefer-default-export export { default as studioHomeMock } from './studioHomeMock'; -export { default as listStudioHomeV2LibrariesMock } from './listStudioHomeV2LibrariesMock'; diff --git a/src/studio-home/data/api.test.js b/src/studio-home/data/api.test.js index 5d255d7fb2..329d05b3b2 100644 --- a/src/studio-home/data/api.test.js +++ b/src/studio-home/data/api.test.js @@ -14,12 +14,10 @@ import { getStudioHomeCoursesV2, getStudioHomeLibraries, } from './api'; -import { getStudioHomeLibrariesV2 } from '../../library/data/api'; import { generateGetStudioCoursesApiResponse, generateGetStudioHomeDataApiResponse, generateGetStudioHomeLibrariesApiResponse, - generateGetStudioHomeLibrariesV2ApiResponse, } from '../factories/mockApiResponses'; let axiosMock; @@ -80,16 +78,6 @@ describe('studio-home api calls', () => { expect(result).toEqual(expected); }); - it('should get studio v2 libraries data', async () => { - const apiLink = `${getApiBaseUrl()}/api/libraries/v2/`; - axiosMock.onGet(apiLink).reply(200, generateGetStudioHomeLibrariesV2ApiResponse()); - const result = await getStudioHomeLibrariesV2({}); - const expected = generateGetStudioHomeLibrariesV2ApiResponse(); - - expect(axiosMock.history.get[0].url).toEqual(apiLink); - expect(result).toEqual(expected); - }); - it('should handle course notification request', async () => { const dismissLink = 'to://dismiss-link'; const successResponse = { status: 'OK' }; diff --git a/src/studio-home/data/apiHooks.ts b/src/studio-home/data/apiHooks.ts deleted file mode 100644 index 99e9606fb3..0000000000 --- a/src/studio-home/data/apiHooks.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; - -import { GetLibrariesV2CustomParams, getStudioHomeLibrariesV2 } from '../../library/data/api'; - -/** - * Builds the query to fetch list of V2 Libraries - */ -const useListStudioHomeV2Libraries = (customParams: GetLibrariesV2CustomParams) => ( - useQuery({ - queryKey: ['listV2Libraries', customParams], - queryFn: () => getStudioHomeLibrariesV2(customParams), - keepPreviousData: true, - }) -); - -export default useListStudioHomeV2Libraries; diff --git a/src/studio-home/tabs-section/TabsSection.test.jsx b/src/studio-home/tabs-section/TabsSection.test.jsx index 90f47d5e85..6f40c92121 100644 --- a/src/studio-home/tabs-section/TabsSection.test.jsx +++ b/src/studio-home/tabs-section/TabsSection.test.jsx @@ -11,7 +11,7 @@ import { AppProvider } from '@edx/frontend-platform/react'; import MockAdapter from 'axios-mock-adapter'; import initializeStore from '../../store'; -import { studioHomeMock, listStudioHomeV2LibrariesMock } from '../__mocks__'; +import { studioHomeMock } from '../__mocks__'; import messages from '../messages'; import tabMessages from './messages'; import TabsSection from '.'; @@ -25,26 +25,8 @@ import { import { getApiBaseUrl, getStudioHomeApiUrl } from '../data/api'; import { executeThunk } from '../../utils'; import { fetchLibraryData, fetchStudioHomeData } from '../data/thunks'; - -import useListStudioHomeV2Libraries from '../data/apiHooks'; - -jest.mock('../data/apiHooks', () => ({ - // Since only useListStudioHomeV2Libraries is exported as default - __esModule: true, - default: jest.fn(() => ({ - data: { - next: null, - previous: null, - count: 2, - num_pages: 1, - current_page: 1, - start: 0, - results: [], - }, - isLoading: false, - isError: false, - })), -})); +import { getContentLibraryV2ListApiUrl } from '../../library-authoring/data/api'; +import contentLibrariesListV2 from '../../library-authoring/__mocks__/contentLibrariesListV2'; const { studioShortName } = studioHomeMock; @@ -108,6 +90,7 @@ describe('', () => { ...getConfig(), LIBRARY_MODE: 'mixed', }); + axiosMock.onGet(getContentLibraryV2ListApiUrl()).reply(200, contentLibrariesListV2); }); it('should render all tabs correctly', async () => { @@ -384,12 +367,6 @@ describe('', () => { }); it('should switch to Libraries tab and render specific v2 library details', async () => { - useListStudioHomeV2Libraries.mockReturnValue({ - data: listStudioHomeV2LibrariesMock, - isLoading: false, - isError: false, - }); - render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -403,14 +380,14 @@ describe('', () => { expect(screen.getByText('Showing 2 of 2')).toBeVisible(); - expect(screen.getByText(listStudioHomeV2LibrariesMock.results[0].title)).toBeVisible(); + expect(screen.getByText(contentLibrariesListV2.results[0].title)).toBeVisible(); expect(screen.getByText( - `${listStudioHomeV2LibrariesMock.results[0].org} / ${listStudioHomeV2LibrariesMock.results[0].slug}`, + `${contentLibrariesListV2.results[0].org} / ${contentLibrariesListV2.results[0].slug}`, )).toBeVisible(); - expect(screen.getByText(listStudioHomeV2LibrariesMock.results[1].title)).toBeVisible(); + expect(screen.getByText(contentLibrariesListV2.results[1].title)).toBeVisible(); expect(screen.getByText( - `${listStudioHomeV2LibrariesMock.results[1].org} / ${listStudioHomeV2LibrariesMock.results[1].slug}`, + `${contentLibrariesListV2.results[1].org} / ${contentLibrariesListV2.results[1].slug}`, )).toBeVisible(); }); @@ -444,12 +421,6 @@ describe('', () => { LIBRARY_MODE: 'v2 only', }); - useListStudioHomeV2Libraries.mockReturnValue({ - data: listStudioHomeV2LibrariesMock, - isLoading: false, - isError: false, - }); - render(); axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse()); await executeThunk(fetchStudioHomeData(), store.dispatch); @@ -463,14 +434,14 @@ describe('', () => { expect(screen.getByText('Showing 2 of 2')).toBeVisible(); - expect(screen.getByText(listStudioHomeV2LibrariesMock.results[0].title)).toBeVisible(); + expect(screen.getByText(contentLibrariesListV2.results[0].title)).toBeVisible(); expect(screen.getByText( - `${listStudioHomeV2LibrariesMock.results[0].org} / ${listStudioHomeV2LibrariesMock.results[0].slug}`, + `${contentLibrariesListV2.results[0].org} / ${contentLibrariesListV2.results[0].slug}`, )).toBeVisible(); - expect(screen.getByText(listStudioHomeV2LibrariesMock.results[1].title)).toBeVisible(); + expect(screen.getByText(contentLibrariesListV2.results[1].title)).toBeVisible(); expect(screen.getByText( - `${listStudioHomeV2LibrariesMock.results[1].org} / ${listStudioHomeV2LibrariesMock.results[1].slug}`, + `${contentLibrariesListV2.results[1].org} / ${contentLibrariesListV2.results[1].slug}`, )).toBeVisible(); }); diff --git a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx index 8678a69ea8..f29fa1d6c1 100644 --- a/src/studio-home/tabs-section/libraries-v2-tab/index.tsx +++ b/src/studio-home/tabs-section/libraries-v2-tab/index.tsx @@ -10,8 +10,8 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { getConfig, getPath } from '@edx/frontend-platform'; import { Error } from '@openedx/paragon/icons'; +import { useContentLibraryV2List } from '../../../library-authoring'; import { constructLibraryAuthoringURL } from '../../../utils'; -import useListStudioHomeV2Libraries from '../../data/apiHooks'; import { LoadingSpinner } from '../../../generic/Loading'; import AlertMessage from '../../../generic/alert-message'; import CardItem from '../../card-item'; @@ -45,7 +45,7 @@ const LibrariesV2Tab: React.FC<{ data, isLoading, isError, - } = useListStudioHomeV2Libraries({ page: currentPage, ...filterParams }); + } = useContentLibraryV2List({ page: currentPage, ...filterParams }); if (isLoading && !isFiltered) { return (