Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: create library form (TEMP) #42

Closed
Show file tree
Hide file tree
Changes from 90 commits
Commits
Show all changes
104 commits
Select commit Hold shift + click to select a range
4191ecb
feat: Add lib v2/legacy tabs in studio home
yusuf-musleh May 27, 2024
15c678b
feat: Add `LIBRARY_MODE` config variable
yusuf-musleh May 28, 2024
14b8b3a
feat: Add url paths/navigation for each tab
yusuf-musleh May 28, 2024
515cc71
feat: LibraryV2 redirect to lib mfe or placeholder
yusuf-musleh May 30, 2024
9c6e1e6
feat: Add pagination support for lib v2s
yusuf-musleh May 30, 2024
da189c1
fix: Redirect to placeholder create lib in v2/mixed disabled mfe
yusuf-musleh Jun 3, 2024
85d9ff2
temp: This removes TS code to get tests to run
yusuf-musleh Jun 3, 2024
c6b7bf8
test: Update existing tests to support changes
yusuf-musleh Jun 3, 2024
14933d2
temp: Rename .tsx -> .jsx & .ts -> .js for tests
yusuf-musleh Jun 3, 2024
8b96268
fix: Fix lint issues
yusuf-musleh Jun 3, 2024
f8db853
feat: library home page bare bones
rpenido Jun 4, 2024
7a8488d
test: Add tests for new functionality
yusuf-musleh Jun 4, 2024
7842ce0
fix: update search modal for new library urls
rpenido Jun 5, 2024
462cda9
feat: Add lib v2/legacy tabs in studio home
yusuf-musleh May 27, 2024
be8b2f4
feat: Add `LIBRARY_MODE` config variable
yusuf-musleh May 28, 2024
27b2581
feat: Add url paths/navigation for each tab
yusuf-musleh May 28, 2024
4ffd651
feat: LibraryV2 redirect to lib mfe or placeholder
yusuf-musleh May 30, 2024
7f97243
feat: Add pagination support for lib v2s
yusuf-musleh May 30, 2024
c86b85a
fix: Redirect to placeholder create lib in v2/mixed disabled mfe
yusuf-musleh Jun 3, 2024
efbc625
temp: This removes TS code to get tests to run
yusuf-musleh Jun 3, 2024
21da6f8
test: Update existing tests to support changes
yusuf-musleh Jun 3, 2024
79e6516
temp: Rename .tsx -> .jsx & .ts -> .js for tests
yusuf-musleh Jun 3, 2024
262cb3f
fix: Fix lint issues
yusuf-musleh Jun 3, 2024
1ea229f
test: Add tests for new functionality
yusuf-musleh Jun 4, 2024
a0a30b7
refactor: Change /legacy-libraries -> /libraries-v1
yusuf-musleh Jun 6, 2024
4deaea9
fix: add i18n messages
rpenido Jun 6, 2024
c2bdecf
fix: libraryAuthoring enabled check
rpenido Jun 6, 2024
5213eff
Merge branch 'yusuf-musleh/lib-v2-tab-studio-home' into rpenido/fal-3…
rpenido Jun 6, 2024
a24b3ba
fix: add Create Library placeholder
rpenido Jun 6, 2024
2859741
refactor: Remove hardcoded mfe path
yusuf-musleh Jun 6, 2024
d853f29
refactor: rename .ts files to .js
rpenido Jun 6, 2024
567dcb4
feat: Add function to construct Lib Auth MFE URL
yusuf-musleh Jun 6, 2024
094086e
feat: Make URL /library-v1 when referencing legacy
yusuf-musleh Jun 6, 2024
a95c990
fix: Add missing part of path
yusuf-musleh Jun 6, 2024
e3ebc55
fix: type and lint errors
rpenido Jun 6, 2024
8ed168d
fix: Issue with destinationUrl
yusuf-musleh Jun 6, 2024
beda37f
test: Add checks for Tab.eventKey in tests
yusuf-musleh Jun 6, 2024
2e3fa43
fix: Revert card item url changes to keep simple
yusuf-musleh Jun 7, 2024
72edfac
fix: add tests
rpenido Jun 7, 2024
8086ebf
Merge branch 'yusuf-musleh/lib-v2-tab-studio-home' into rpenido/fal-3…
rpenido Jun 7, 2024
91443e9
fix: removing unused file
rpenido Jun 7, 2024
a29cf7e
fix: add ts-check
rpenido Jun 7, 2024
4deab76
fix: removing deleted file references
rpenido Jun 7, 2024
e8bca34
chore: trigger CI
rpenido Jun 7, 2024
388c40e
fix: fixes from review
rpenido Jun 13, 2024
b827976
feat: create library form
rpenido Jun 14, 2024
7d6096e
fix: fix default parameter syntax
rpenido Jun 14, 2024
c63bc2f
fix: new library redirect
rpenido Jun 18, 2024
efe0bed
Revert "fix: new library redirect"
rpenido Jun 19, 2024
942cbff
chore: trigger CI
rpenido Jun 19, 2024
a82a7bc
feat: add api call
rpenido Jun 20, 2024
2d3be09
fix: new library redirect
rpenido Jun 20, 2024
e8f9f78
fix: remove warning on library home
rpenido Jun 20, 2024
8724ac0
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jun 20, 2024
f9920f4
fix: remove unused import
rpenido Jun 20, 2024
438b726
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jun 20, 2024
bccaedc
fix: add footer to create library form
rpenido Jun 20, 2024
7fd6c27
Merge branch 'master' into rpenido/fal-3753-library-home-page-bare-bones
rpenido Jun 20, 2024
94f483b
fix: merging nits
rpenido Jun 20, 2024
b455f97
fix: change version to slug in header
rpenido Jun 20, 2024
3ae6fe8
fix: header outline link for libraries
rpenido Jun 20, 2024
e6b877d
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jun 20, 2024
1045687
refactor: form refactor and tests
rpenido Jun 21, 2024
38b578e
fix: set default for ts-check
rpenido Jun 21, 2024
e972fb0
Merge branch 'master' into rpenido/fal-3753-library-home-page-bare-bones
rpenido Jun 21, 2024
55acb06
refactor: test renaming to ts
rpenido Jun 21, 2024
e3daf6a
refactor: test renaming to ts
rpenido Jun 22, 2024
645b3de
refactor: migrating to typescript
rpenido Jun 22, 2024
a008837
fix: add description to api call
rpenido Jun 23, 2024
d131937
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jun 23, 2024
a1a0ad8
refactor: renaming files js -> ts
rpenido Jun 24, 2024
87358b7
fix: update typescript code
rpenido Jun 24, 2024
0b4e90e
Merge branch 'master' into rpenido/fal-3753-library-home-page-bare-bones
rpenido Jun 24, 2024
529d76a
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jun 24, 2024
037018b
feat: remove file
rpenido Jun 24, 2024
fabc561
refactor: renaming js -> ts
rpenido Jun 24, 2024
ae93c8e
fix: update typescript code
rpenido Jun 24, 2024
61661a8
feat: better error handling and test
rpenido Jun 24, 2024
708426c
fix: slug regex
rpenido Jun 24, 2024
8d71297
fix: remove @ts-check and move CreateContentLibraryArgs type
rpenido Jun 25, 2024
51d36ce
fix: remove @ts-check
rpenido Jun 25, 2024
fbd418d
fix: remove unused file
rpenido Jun 25, 2024
dee7c0b
Merge branch 'master' into rpenido/fal-3753-library-home-page-bare-bones
rpenido Jun 30, 2024
2a5d42b
fix: optional parameters
rpenido Jun 30, 2024
62fe7f5
chore: trigger CI
rpenido Jun 30, 2024
ca33c18
Merge branch 'master' into rpenido/fal-3753-library-home-page-bare-bones
rpenido Jul 3, 2024
a4c42b4
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jul 3, 2024
c447036
refactor: remove axios dependency
rpenido Jul 3, 2024
9528bfd
chore(deps): update dependency meilisearch to ^0.41.0 (#1136)
renovate[bot] Jul 8, 2024
8cf26e1
Version bump for Paragon to 22.6.1, with stricter typing (#1146)
bradenmacdonald Jul 8, 2024
83489b0
feat: Add filters/sorting for the libraries v2 tab on studio home (#1…
yusuf-musleh Jul 8, 2024
d5fc6fc
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jul 8, 2024
6a99b48
fix: merging errors
rpenido Jul 8, 2024
63e7593
fix: rename contentId -> contextId and add typings
rpenido Jul 9, 2024
5f4ebbd
refactor: renaming index.js -> index.ts
rpenido Jul 9, 2024
01d4b85
perf: lockfile version check workflow file updated (#1107)
huniafatima-arbi Jul 10, 2024
09822c2
chore: update browserslist DB (#443)
edx-requirements-bot Jul 10, 2024
f16c1df
Merge branch 'rpenido/fal-3753-library-home-page-bare-bones' into rpe…
rpenido Jul 10, 2024
a7fe999
feat: add types to createLibraryV2 api
rpenido Jul 10, 2024
9a23204
refactor: change code to use useMutate values instead of states
rpenido Jul 10, 2024
117b4f1
chore: remove core-js and regenerator-runtime (#1032)
bradenmacdonald Jul 10, 2024
f60ddb5
feat: library home page ("bare bones") (#1076)
rpenido Jul 10, 2024
71fcf9f
fix: only show course blocks in the search modal (#1148)
rpenido Jul 10, 2024
fcef5ba
Merge branch 'master' into rpenido/fal-3768-create-new-library-form
rpenido Jul 10, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -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*$/,
};
4 changes: 2 additions & 2 deletions src/generic/FormikControl.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ const FormikControl = ({
name,
label,
help,
className,
controlClasses,
className = FormikControl.defaultProps.className,
controlClasses = FormikControl.defaultProps.controlClasses,
...params
}) => {
const {
Expand Down
5 changes: 3 additions & 2 deletions src/generic/create-or-rerun-course/hooks.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
Expand Down
2 changes: 1 addition & 1 deletion src/generic/data/apiHooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getOrganizations, getTagsCount } from './api';
export const useOrganizationListData = () => (
useQuery({
queryKey: ['organizationList'],
queryFn: () => getOrganizations(),
queryFn: getOrganizations,
})
);

Expand Down
27 changes: 0 additions & 27 deletions src/library-authoring/CreateLibrary.tsx

This file was deleted.

131 changes: 131 additions & 0 deletions src/library-authoring/create-library/CreateLibrary.test.tsx
Original file line number Diff line number Diff line change
@@ -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, 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).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"}');
});
});
});
147 changes: 147 additions & 0 deletions src/library-authoring/create-library/CreateLibrary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import React, { useState } from 'react';
import { StudioFooter } from '@edx/frontend-component-footer';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
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 { useOrganizationListData } from '../../generic/data/apiHooks';
import SubHeader from '../../generic/sub-header/SubHeader';
import type { CreateContentLibraryArgs } from './data/api';
import { useCreateLibraryV2 } from './data/apiHooks';
import messages from './messages';

const CreateLibrary = () => {
const intl = useIntl();
const navigate = useNavigate();

const [apiError, setApiError] = useState<React.ReactNode>();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to create your own apiError state. When you call useCreateLibraryV2() below, it returns an error and isError state variables that you can use. It also returns an isLoading which you can use to disable the UI and display a spinner while the API call happens. See https://tanstack.com/query/v4/docs/framework/react/reference/useMutation

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you @bradenmacdonald!
Didn't think this way the first time. I liked it: 9a23204


const { noSpaceRule, specialCharsRule } = REGEX_RULES;
const validSlugIdRegex = /^[a-zA-Z\d]+(?:[\w-]*[a-zA-Z\d]+)*$/;

const {
mutateAsync,
} = useCreateLibraryV2();

const {
data: organizationListData,
isLoading: isOrganizationListLoading,
} = useOrganizationListData();

return (
<>
<Header isHiddenMainMenu />
<Container size="xl" className="p-4 mt-3">
<SubHeader
title={<FormattedMessage {...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={async (values: CreateContentLibraryArgs) => {
setApiError(undefined);
try {
const data = await mutateAsync(values);
navigate(`/library/${data.id}`);
} catch (error: any) {
setApiError((
<>
{error.message}
<br />
{error.response?.data && JSON.stringify(error.response.data)}
</>
));
}
}}
>
{(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)}
/>
<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)}
/>
<StatefulButton
type="submit"
variant="primary"
className="action btn-primary"
state={formikProps.isSubmitting ? 'disabled' : 'enabled'}
disabledStates={['disabled']}
labels={{
enabled: intl.formatMessage(messages.createLibraryButton),
disabled: intl.formatMessage(messages.createLibraryButtonPending),
}}
/>
</Form>
)}
</Formik>
{apiError && (
<Alert variant="danger" className="mt-3">
{apiError}
</Alert>
)}
</Container>
<StudioFooter />
</>
);
};

export default CreateLibrary;
28 changes: 28 additions & 0 deletions src/library-authoring/create-library/data/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';

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) {
bradenmacdonald marked this conversation as resolved.
Show resolved Hide resolved
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);
}
Loading
Loading