feat: create library (v2) form (#1116)

This commit is contained in:
Rômulo Penido
2024-07-12 14:54:37 +02:00
committed by GitHub
parent cc41a2fda1
commit e087001905
27 changed files with 570 additions and 185 deletions

View File

@@ -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*$/,
};

View 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();
});
});

View 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;

View File

@@ -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),

View File

@@ -8,7 +8,7 @@ import { getOrganizations, getTagsCount } from './api';
export const useOrganizationListData = () => (
useQuery({
queryKey: ['organizationList'],
queryFn: () => getOrganizations(),
queryFn: getOrganizations,
})
);

View File

@@ -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;

View File

@@ -62,6 +62,7 @@ const libraryData: ContentLibrary = {
hasUnpublishedChanges: true,
hasUnpublishedDeletes: false,
license: '',
canEditLibrary: false,
};
const RootWrapper = () => (

View File

@@ -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 {

View File

@@ -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 = {

View File

@@ -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 }) => (

View 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"}');
});
});
});

View 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;

View 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);
}

View 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() });
},
});
};

View File

@@ -0,0 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as CreateLibrary } from './CreateLibrary';

View 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;

View File

@@ -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);
}

View File

@@ -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,
})
);

View File

@@ -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';

View File

@@ -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',

View File

@@ -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);
}

View File

@@ -1,2 +1,2 @@
// eslint-disable-next-line import/prefer-default-export
export { default as studioHomeMock } from './studioHomeMock';
export { default as listStudioHomeV2LibrariesMock } from './listStudioHomeV2LibrariesMock';

View File

@@ -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' };

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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 (