feat: create library (v2) form (#1116)
This commit is contained in:
@@ -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*$/,
|
||||
};
|
||||
|
||||
40
src/generic/alert-error/AlertError.test.tsx
Normal file
40
src/generic/alert-error/AlertError.test.tsx
Normal file
@@ -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 }) => (
|
||||
<IntlProvider locale="en">
|
||||
<AlertError error={error} />
|
||||
</IntlProvider>
|
||||
);
|
||||
|
||||
describe('<AlertMessage />', () => {
|
||||
test('render using a string', () => {
|
||||
const error = 'This is a string error message';
|
||||
const { getByText } = render(<RootWrapper error={error} />);
|
||||
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(<RootWrapper error={error} />);
|
||||
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(<RootWrapper error={error} />);
|
||||
screen.logTestingPlaygroundURL();
|
||||
expect(getByText(/this is an error message/i)).toBeInTheDocument();
|
||||
expect(getByText(/\{"message":"this is a response body"\}/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
14
src/generic/alert-error/index.tsx
Normal file
14
src/generic/alert-error/index.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Alert,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
const AlertError: React.FC<{ error: unknown }> = ({ error }) => (
|
||||
<Alert variant="danger" className="mt-3">
|
||||
{error instanceof Object && 'message' in error ? error.message : String(error)}
|
||||
<br />
|
||||
{error instanceof Object && (error as any).response?.data && JSON.stringify((error as any).response?.data)}
|
||||
</Alert>
|
||||
);
|
||||
|
||||
export default AlertError;
|
||||
@@ -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),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getOrganizations, getTagsCount } from './api';
|
||||
export const useOrganizationListData = () => (
|
||||
useQuery({
|
||||
queryKey: ['organizationList'],
|
||||
queryFn: () => getOrganizations(),
|
||||
queryFn: getOrganizations,
|
||||
})
|
||||
);
|
||||
|
||||
|
||||
@@ -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 = () => (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<SubHeader
|
||||
title={<FormattedMessage {...messages.createLibrary} />}
|
||||
/>
|
||||
<div className="d-flex my-6 justify-content-center">
|
||||
<FormattedMessage
|
||||
{...messages.createLibraryTempPlaceholder}
|
||||
/>
|
||||
</div>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
|
||||
export default CreateLibrary;
|
||||
@@ -62,6 +62,7 @@ const libraryData: ContentLibrary = {
|
||||
hasUnpublishedChanges: true,
|
||||
hasUnpublishedDeletes: false,
|
||||
license: '',
|
||||
canEditLibrary: false,
|
||||
};
|
||||
|
||||
const RootWrapper = () => (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 }) => (
|
||||
|
||||
131
src/library-authoring/create-library/CreateLibrary.test.tsx
Normal file
131
src/library-authoring/create-library/CreateLibrary.test.tsx
Normal file
@@ -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 = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<CreateLibrary />
|
||||
</QueryClientProvider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
|
||||
describe('<CreateLibrary />', () => {
|
||||
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(<RootWrapper />);
|
||||
|
||||
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(<RootWrapper />);
|
||||
|
||||
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"}');
|
||||
});
|
||||
});
|
||||
});
|
||||
138
src/library-authoring/create-library/CreateLibrary.tsx
Normal file
138
src/library-authoring/create-library/CreateLibrary.tsx
Normal file
@@ -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 (
|
||||
<>
|
||||
<Header isHiddenMainMenu />
|
||||
<Container size="xl" className="p-4 mt-3">
|
||||
<SubHeader
|
||||
title={intl.formatMessage(messages.createLibrary)}
|
||||
/>
|
||||
<Formik
|
||||
initialValues={{
|
||||
title: '',
|
||||
org: '',
|
||||
slug: '',
|
||||
}}
|
||||
validationSchema={
|
||||
Yup.object().shape({
|
||||
title: 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)),
|
||||
slug: Yup.string()
|
||||
.required(intl.formatMessage(messages.requiredFieldError))
|
||||
.matches(
|
||||
validSlugIdRegex,
|
||||
intl.formatMessage(messages.invalidSlugError),
|
||||
),
|
||||
})
|
||||
}
|
||||
onSubmit={(values) => mutate(values)}
|
||||
>
|
||||
{(formikProps) => (
|
||||
<Form onSubmit={formikProps.handleSubmit}>
|
||||
<FormikControl
|
||||
name="title"
|
||||
label={<Form.Label>{intl.formatMessage(messages.titleLabel)}</Form.Label>}
|
||||
value={formikProps.values.title}
|
||||
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', event.selectionId)}
|
||||
placeholder={intl.formatMessage(messages.orgPlaceholder)}
|
||||
>
|
||||
{organizationListData ? organizationListData.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="slug"
|
||||
label={<Form.Label>{intl.formatMessage(messages.slugLabel)}</Form.Label>}
|
||||
value={formikProps.values.slug}
|
||||
placeholder={intl.formatMessage(messages.slugPlaceholder)}
|
||||
help={intl.formatMessage(messages.slugHelp)}
|
||||
className=""
|
||||
controlClasses="pb-2"
|
||||
/>
|
||||
<StatefulButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
className="action btn-primary"
|
||||
state={isLoading ? 'disabled' : 'enabled'}
|
||||
disabledStates={['disabled']}
|
||||
labels={{
|
||||
enabled: intl.formatMessage(messages.createLibraryButton),
|
||||
disabled: intl.formatMessage(messages.createLibraryButtonPending),
|
||||
}}
|
||||
/>
|
||||
</Form>
|
||||
)}
|
||||
</Formik>
|
||||
{isError && (<AlertError error={error} />)}
|
||||
</Container>
|
||||
<StudioFooter />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateLibrary;
|
||||
30
src/library-authoring/create-library/data/api.ts
Normal file
30
src/library-authoring/create-library/data/api.ts
Normal file
@@ -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<ContentLibrary> {
|
||||
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);
|
||||
}
|
||||
22
src/library-authoring/create-library/data/apiHooks.ts
Normal file
22
src/library-authoring/create-library/data/apiHooks.ts
Normal file
@@ -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() });
|
||||
},
|
||||
});
|
||||
};
|
||||
2
src/library-authoring/create-library/index.ts
Normal file
2
src/library-authoring/create-library/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as CreateLibrary } from './CreateLibrary';
|
||||
88
src/library-authoring/create-library/messages.ts
Normal file
88
src/library-authoring/create-library/messages.ts
Normal file
@@ -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;
|
||||
@@ -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<ContentLibr
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentLibraryApiUrl(libraryId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
export interface LibrariesV2Response {
|
||||
next: string | null,
|
||||
previous: string | null,
|
||||
count: number,
|
||||
numPages: number,
|
||||
currentPage: number,
|
||||
start: number,
|
||||
results: ContentLibrary[],
|
||||
}
|
||||
|
||||
/* 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 a list of content libraries.
|
||||
*/
|
||||
export async function getContentLibraryV2List(customParams: GetLibrariesV2CustomParams): Promise<LibrariesV2Response> {
|
||||
// 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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<LibrariesV2Response> {
|
||||
// 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);
|
||||
}
|
||||
@@ -1,2 +1,2 @@
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export { default as studioHomeMock } from './studioHomeMock';
|
||||
export { default as listStudioHomeV2LibrariesMock } from './listStudioHomeV2LibrariesMock';
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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;
|
||||
@@ -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('<TabsSection />', () => {
|
||||
...getConfig(),
|
||||
LIBRARY_MODE: 'mixed',
|
||||
});
|
||||
axiosMock.onGet(getContentLibraryV2ListApiUrl()).reply(200, contentLibrariesListV2);
|
||||
});
|
||||
|
||||
it('should render all tabs correctly', async () => {
|
||||
@@ -384,12 +367,6 @@ describe('<TabsSection />', () => {
|
||||
});
|
||||
|
||||
it('should switch to Libraries tab and render specific v2 library details', async () => {
|
||||
useListStudioHomeV2Libraries.mockReturnValue({
|
||||
data: listStudioHomeV2LibrariesMock,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
|
||||
await executeThunk(fetchStudioHomeData(), store.dispatch);
|
||||
@@ -403,14 +380,14 @@ describe('<TabsSection />', () => {
|
||||
|
||||
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('<TabsSection />', () => {
|
||||
LIBRARY_MODE: 'v2 only',
|
||||
});
|
||||
|
||||
useListStudioHomeV2Libraries.mockReturnValue({
|
||||
data: listStudioHomeV2LibrariesMock,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
render(<RootWrapper />);
|
||||
axiosMock.onGet(getStudioHomeApiUrl()).reply(200, generateGetStudioHomeDataApiResponse());
|
||||
await executeThunk(fetchStudioHomeData(), store.dispatch);
|
||||
@@ -463,14 +434,14 @@ describe('<TabsSection />', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user