mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +00:00
feat(content-releases): Redirect to /content-releases/:releaseId onCreate and add header on details page (#18720)
* draft implementation details header * implementation details ui with mock data * fix unit tests * fix fernando comments * update pages structure * first raw implementation store with rtk * refactor(releases): redux toolkit query work * rename releases page * merge feature/content-releases * test(releases): setup test harness for working with the admin app (#18817) * test(releases): setup test harness for working with the admin app * chore: remove file that shouldn't be here * rename releases page * merge "content-releases/release-details-redirect-after-creation" * test(releases): setup test harness for working with the admin app * rename releases page * merge "content-releases/release-details-redirect-after-creation" --------- Co-authored-by: Simone Taeggi <startae14@gmail.com> * fix Fernando's review comments --------- Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>
This commit is contained in:
parent
5ca75a1812
commit
f9fb2e7c49
@ -64,6 +64,7 @@ const getAdminDependencyAliases = (monorepo?: StrapiMonorepo) =>
|
||||
*/
|
||||
const devAliases: Record<string, string> = {
|
||||
'@strapi/admin/strapi-admin': './packages/core/admin/admin/src',
|
||||
'@strapi/content-releases/strapi-admin': './packages/core/content-releases/admin/src',
|
||||
'@strapi/content-type-builder/strapi-admin': './packages/core/content-type-builder/admin/src',
|
||||
'@strapi/email/strapi-admin': './packages/core/email/admin/src',
|
||||
'@strapi/upload/strapi-admin': './packages/core/upload/admin/src',
|
||||
|
18
packages/core/content-releases/admin/.eslintrc
Normal file
18
packages/core/content-releases/admin/.eslintrc
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
"root": true,
|
||||
"extends": ["custom/front/typescript"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["./tests/*", "**/*.test.*"],
|
||||
"env": {
|
||||
"jest": true
|
||||
},
|
||||
"rules": {
|
||||
/**
|
||||
* So we can do `import { render } from '@tests/utils'`
|
||||
*/
|
||||
"import/no-unresolved": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
@ -1,4 +0,0 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
extends: ['custom/front/typescript'],
|
||||
};
|
@ -7,18 +7,26 @@ import {
|
||||
TextInput,
|
||||
Typography,
|
||||
} from '@strapi/design-system';
|
||||
import { useNotification } from '@strapi/helper-plugin';
|
||||
import { useAPIErrorHandler, useNotification } from '@strapi/helper-plugin';
|
||||
import { Formik, Form } from 'formik';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import * as yup from 'yup';
|
||||
|
||||
const releaseSchema = yup.object({
|
||||
import { useCreateReleaseMutation } from '../modules/releaseSlice';
|
||||
import { isErrorAxiosError } from '../utils/errors';
|
||||
|
||||
const RELEASE_SCHEMA = yup.object({
|
||||
name: yup.string().required(),
|
||||
});
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
}
|
||||
|
||||
const INITIAL_VALUES = {
|
||||
name: '',
|
||||
};
|
||||
} satisfies FormValues;
|
||||
|
||||
interface AddReleaseDialogProps {
|
||||
handleClose: () => void;
|
||||
@ -27,17 +35,36 @@ interface AddReleaseDialogProps {
|
||||
export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const toggleNotification = useNotification();
|
||||
const { push } = useHistory();
|
||||
const { formatAPIError } = useAPIErrorHandler();
|
||||
|
||||
const handleSubmit = () => {
|
||||
handleClose();
|
||||
const [createRelease, { isLoading }] = useCreateReleaseMutation();
|
||||
|
||||
toggleNotification({
|
||||
type: 'success',
|
||||
message: formatMessage({
|
||||
id: 'content-releases.modal.release-created-notification-success',
|
||||
defaultMessage: 'Release created.',
|
||||
}),
|
||||
const handleSubmit = async (values: FormValues) => {
|
||||
const response = await createRelease({
|
||||
name: values.name,
|
||||
});
|
||||
|
||||
if ('data' in response) {
|
||||
toggleNotification({
|
||||
type: 'success',
|
||||
message: formatMessage({
|
||||
id: 'content-releases.modal.release-created-notification-success',
|
||||
defaultMessage: 'Release created.',
|
||||
}),
|
||||
});
|
||||
push(`/plugins/content-releases/${response.data.data.id}`);
|
||||
} else if (isErrorAxiosError(response.error)) {
|
||||
toggleNotification({
|
||||
type: 'warning',
|
||||
message: formatAPIError(response.error),
|
||||
});
|
||||
} else {
|
||||
toggleNotification({
|
||||
type: 'warning',
|
||||
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@ -54,7 +81,7 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
|
||||
validateOnChange={false}
|
||||
onSubmit={handleSubmit}
|
||||
initialValues={INITIAL_VALUES}
|
||||
validationSchema={releaseSchema}
|
||||
validationSchema={RELEASE_SCHEMA}
|
||||
>
|
||||
{({ values, errors, handleChange }) => (
|
||||
<Form>
|
||||
@ -78,7 +105,7 @@ export const AddReleaseDialog = ({ handleClose }: AddReleaseDialogProps) => {
|
||||
</Button>
|
||||
}
|
||||
endActions={
|
||||
<Button name="submit" disabled={!values.name} type="submit">
|
||||
<Button name="submit" loading={isLoading} disabled={!values.name} type="submit">
|
||||
{formatMessage({
|
||||
id: 'content-releases.modal.form.button.submit',
|
||||
defaultMessage: 'Continue',
|
||||
|
@ -2,6 +2,7 @@ import { prefixPluginTranslations } from '@strapi/helper-plugin';
|
||||
import { PaperPlane } from '@strapi/icons';
|
||||
|
||||
import { PERMISSIONS } from './constants';
|
||||
import { releaseApi } from './modules/releaseSlice';
|
||||
import { pluginId } from './pluginId';
|
||||
|
||||
import type { Plugin } from '@strapi/types';
|
||||
@ -19,11 +20,21 @@ const admin: Plugin.Config.AdminInput = {
|
||||
defaultMessage: 'Releases',
|
||||
},
|
||||
async Component() {
|
||||
const { Releases } = await import('./pages/App');
|
||||
return Releases;
|
||||
const { App } = await import('./pages/App');
|
||||
return App;
|
||||
},
|
||||
permissions: PERMISSIONS.main,
|
||||
});
|
||||
|
||||
/**
|
||||
* For some reason every middleware you pass has to a function
|
||||
* that returns the actual middleware. It's annoying but no one knows why....
|
||||
*/
|
||||
app.addMiddlewares([() => releaseApi.middleware]);
|
||||
|
||||
app.addReducers({
|
||||
[releaseApi.reducerPath]: releaseApi.reducer,
|
||||
});
|
||||
}
|
||||
},
|
||||
async registerTrads({ locales }: { locales: string[] }) {
|
||||
|
@ -0,0 +1,39 @@
|
||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
|
||||
import { pluginId } from '../pluginId';
|
||||
import { axiosBaseQuery } from '../utils/data';
|
||||
|
||||
import type { CreateRelease, GetAllReleases } from '../../../shared/contracts/releases';
|
||||
|
||||
const releaseApi = createApi({
|
||||
reducerPath: pluginId,
|
||||
baseQuery: axiosBaseQuery,
|
||||
tagTypes: ['Releases'],
|
||||
endpoints: (build) => {
|
||||
return {
|
||||
getRelease: build.query<GetAllReleases.Response, undefined>({
|
||||
query() {
|
||||
return {
|
||||
url: '/content-releases',
|
||||
method: 'GET',
|
||||
};
|
||||
},
|
||||
providesTags: ['Releases'],
|
||||
}),
|
||||
createRelease: build.mutation<CreateRelease.Response, CreateRelease.Request['body']>({
|
||||
query(data) {
|
||||
return {
|
||||
url: '/content-releases',
|
||||
method: 'POST',
|
||||
data,
|
||||
};
|
||||
},
|
||||
invalidatesTags: ['Releases'],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const { useGetReleaseQuery, useCreateReleaseMutation } = releaseApi;
|
||||
|
||||
export { useGetReleaseQuery, useCreateReleaseMutation, releaseApi };
|
@ -3,16 +3,18 @@ import { Route, Switch } from 'react-router-dom';
|
||||
|
||||
import { pluginId } from '../pluginId';
|
||||
|
||||
import { ProtectedReleaseDetailsPage } from './ReleaseDetailsPage';
|
||||
import { ProtectedReleasesPage } from './ReleasesPage';
|
||||
|
||||
export const Releases = () => {
|
||||
export const App = () => {
|
||||
return (
|
||||
<Main>
|
||||
<Switch>
|
||||
<Route exact path={`/plugins/${pluginId}`} component={ProtectedReleasesPage} />
|
||||
<Route
|
||||
exact
|
||||
path={`/plugins/${pluginId}/:releaseId`}
|
||||
render={() => <div>TODO: This is the DetailsPage</div>}
|
||||
component={ProtectedReleaseDetailsPage}
|
||||
/>
|
||||
</Switch>
|
||||
</Main>
|
||||
|
@ -0,0 +1,199 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import {
|
||||
Button,
|
||||
ContentLayout,
|
||||
EmptyStateLayout,
|
||||
Flex,
|
||||
HeaderLayout,
|
||||
IconButton,
|
||||
Link,
|
||||
Popover,
|
||||
Typography,
|
||||
} from '@strapi/design-system';
|
||||
import { CheckPermissions } from '@strapi/helper-plugin';
|
||||
import { ArrowLeft, EmptyDocuments, More, Pencil, Trash } from '@strapi/icons';
|
||||
import { useIntl } from 'react-intl';
|
||||
import styled from 'styled-components';
|
||||
|
||||
import { PERMISSIONS } from '../constants';
|
||||
|
||||
const PopoverButton = styled(Flex)`
|
||||
align-self: stretch;
|
||||
`;
|
||||
|
||||
const PencilIcon = styled(Pencil)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
path {
|
||||
fill: ${({ theme }) => theme.colors.neutral600};
|
||||
}
|
||||
`;
|
||||
|
||||
const TrashIcon = styled(Trash)`
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
path {
|
||||
fill: ${({ theme }) => theme.colors.danger600};
|
||||
}
|
||||
`;
|
||||
|
||||
const ReleaseInfoWrapper = styled(Flex)`
|
||||
align-self: stretch;
|
||||
border-radius: 0 0 4px 4px;
|
||||
border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
|
||||
`;
|
||||
|
||||
const ReleaseDetailsPage = () => {
|
||||
const [isPopoverVisible, setIsPopoverVisible] = React.useState(false);
|
||||
const moreButtonRef = React.useRef<HTMLButtonElement>(null!);
|
||||
const { formatMessage } = useIntl();
|
||||
// TODO: get the title from the API
|
||||
const title = 'Release title';
|
||||
|
||||
const totalEntries = 0; // TODO: replace it with the total number of entries
|
||||
const days = 0; // TODO: replace it with the number of days since the release was created
|
||||
const createdBy = 'John Doe'; // TODO: replace it with the name of the user who created the release
|
||||
|
||||
const handleTogglePopover = () => {
|
||||
setIsPopoverVisible((prev) => !prev);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HeaderLayout
|
||||
title={title}
|
||||
subtitle={formatMessage(
|
||||
{
|
||||
id: 'content-releases.pages.Details.header-subtitle',
|
||||
defaultMessage: '{number, plural, =0 {No entries} one {# entry} other {# entries}}',
|
||||
},
|
||||
{ number: totalEntries }
|
||||
)}
|
||||
navigationAction={
|
||||
<Link startIcon={<ArrowLeft />} to="/plugins/content-releases">
|
||||
{formatMessage({
|
||||
id: 'global.back',
|
||||
defaultMessage: 'Back',
|
||||
})}
|
||||
</Link>
|
||||
}
|
||||
primaryAction={
|
||||
<Flex gap={2}>
|
||||
<IconButton
|
||||
label={formatMessage({
|
||||
id: 'content-releases.header.actions.open-release-actions',
|
||||
defaultMessage: 'Release actions',
|
||||
})}
|
||||
onClick={handleTogglePopover}
|
||||
ref={moreButtonRef}
|
||||
>
|
||||
<More />
|
||||
</IconButton>
|
||||
{isPopoverVisible && (
|
||||
<Popover
|
||||
source={moreButtonRef}
|
||||
placement="bottom-end"
|
||||
onDismiss={handleTogglePopover}
|
||||
spacing={4}
|
||||
minWidth="242px"
|
||||
>
|
||||
<Flex alignItems="center" justifyContent="center" direction="column" padding={1}>
|
||||
<PopoverButton
|
||||
paddingTop={2}
|
||||
paddingBottom={2}
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
as="button"
|
||||
borderRadius="4px"
|
||||
>
|
||||
<PencilIcon />
|
||||
<Typography ellipsis>
|
||||
{formatMessage({
|
||||
id: 'content-releases.header.actions.edit',
|
||||
defaultMessage: 'Edit',
|
||||
})}
|
||||
</Typography>
|
||||
</PopoverButton>
|
||||
<PopoverButton
|
||||
paddingTop={2}
|
||||
paddingBottom={2}
|
||||
paddingLeft={4}
|
||||
paddingRight={4}
|
||||
alignItems="center"
|
||||
gap={2}
|
||||
as="button"
|
||||
borderRadius="4px"
|
||||
>
|
||||
<TrashIcon />
|
||||
<Typography ellipsis textColor="danger600">
|
||||
{formatMessage({
|
||||
id: 'content-releases.header.actions.delete',
|
||||
defaultMessage: 'Delete',
|
||||
})}
|
||||
</Typography>
|
||||
</PopoverButton>
|
||||
</Flex>
|
||||
<ReleaseInfoWrapper
|
||||
direction="column"
|
||||
justifyContent="center"
|
||||
alignItems="flex-start"
|
||||
gap={1}
|
||||
padding={5}
|
||||
>
|
||||
<Typography variant="pi" fontWeight="bold">
|
||||
{formatMessage({
|
||||
id: 'content-releases.header.actions.created',
|
||||
defaultMessage: 'Created',
|
||||
})}
|
||||
</Typography>
|
||||
<Typography variant="pi" color="neutral300">
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'content-releases.header.actions.created.description',
|
||||
defaultMessage:
|
||||
'{number, plural, =0 {# days} one {# day} other {# days}} ago by {createdBy}',
|
||||
},
|
||||
{ number: days, createdBy }
|
||||
)}
|
||||
</Typography>
|
||||
</ReleaseInfoWrapper>
|
||||
</Popover>
|
||||
)}
|
||||
<Button size="S" variant="tertiary">
|
||||
{formatMessage({
|
||||
id: 'content-releases.header.actions.refresh',
|
||||
defaultMessage: 'Refresh',
|
||||
})}
|
||||
</Button>
|
||||
<Button size="S" disabled={true} variant="default">
|
||||
{formatMessage({
|
||||
id: 'content-releases.header.actions.release',
|
||||
defaultMessage: 'Release',
|
||||
})}
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<ContentLayout>
|
||||
<EmptyStateLayout
|
||||
content={formatMessage({
|
||||
id: 'content-releases.pages.Details.empty-state.content',
|
||||
defaultMessage: 'This release is empty.',
|
||||
})}
|
||||
icon={<EmptyDocuments width="10rem" />}
|
||||
/>
|
||||
</ContentLayout>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const ProtectedReleaseDetailsPage = () => (
|
||||
<CheckPermissions permissions={PERMISSIONS.main}>
|
||||
<ReleaseDetailsPage />
|
||||
</CheckPermissions>
|
||||
);
|
||||
|
||||
export { ReleaseDetailsPage, ProtectedReleaseDetailsPage };
|
@ -1,7 +1,7 @@
|
||||
import * as React from 'react';
|
||||
|
||||
import { Button, HeaderLayout } from '@strapi/design-system';
|
||||
import { CheckPermissions, CheckPagePermissions } from '@strapi/helper-plugin';
|
||||
import { CheckPermissions } from '@strapi/helper-plugin';
|
||||
import { Plus } from '@strapi/icons';
|
||||
import { useIntl } from 'react-intl';
|
||||
|
||||
|
@ -0,0 +1,36 @@
|
||||
import { render, screen } from '@tests/utils';
|
||||
|
||||
import { ReleaseDetailsPage } from '../ReleaseDetailsPage';
|
||||
|
||||
describe('Release details page', () => {
|
||||
it('renders correctly the heading content', async () => {
|
||||
const { user } = render(<ReleaseDetailsPage />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Release title');
|
||||
// if there are 0 entries
|
||||
expect(screen.getByText('No entries')).toBeInTheDocument();
|
||||
|
||||
const refreshButton = screen.getByRole('button', { name: 'Refresh' });
|
||||
expect(refreshButton).toBeInTheDocument();
|
||||
|
||||
const releaseButton = screen.getByRole('button', { name: 'Release' });
|
||||
expect(releaseButton).toBeInTheDocument();
|
||||
|
||||
const moreButton = screen.getByRole('button', { name: 'Release actions' });
|
||||
expect(moreButton).toBeInTheDocument();
|
||||
|
||||
await user.click(moreButton);
|
||||
|
||||
// shows the popover actions
|
||||
const editButton = screen.getByRole('button', { name: 'Edit' });
|
||||
expect(editButton).toBeInTheDocument();
|
||||
|
||||
const deleteButton = screen.getByRole('button', { name: 'Delete' });
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty content if there are no entries', async () => {
|
||||
render(<ReleaseDetailsPage />);
|
||||
|
||||
expect(screen.getByText('No entries')).toBeInTheDocument();
|
||||
});
|
||||
});
|
@ -1,36 +1,18 @@
|
||||
import { lightTheme, ThemeProvider } from '@strapi/design-system';
|
||||
import { render as renderRTL, within, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { within, screen } from '@testing-library/react';
|
||||
import { render } from '@tests/utils';
|
||||
|
||||
import { ReleasesPage } from '../ReleasesPage';
|
||||
|
||||
const user = userEvent.setup();
|
||||
|
||||
jest.mock('@strapi/helper-plugin', () => ({
|
||||
...jest.requireActual('@strapi/helper-plugin'),
|
||||
// eslint-disable-next-line
|
||||
CheckPermissions: ({ children }: { children: JSX.Element}) => <div>{children}</div>
|
||||
}));
|
||||
|
||||
const render = () =>
|
||||
renderRTL(
|
||||
<ThemeProvider theme={lightTheme}>
|
||||
<IntlProvider locale="en" messages={{}} defaultLocale="en">
|
||||
<ReleasesPage />
|
||||
</IntlProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
describe('Releases home page', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders correctly the heading content', async () => {
|
||||
render();
|
||||
|
||||
() => expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Releases');
|
||||
const { user } = render(<ReleasesPage />);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Releases');
|
||||
// if there are 0 releases
|
||||
expect(screen.getByText('No releases')).toBeInTheDocument();
|
||||
|
||||
@ -51,7 +33,7 @@ describe('Releases home page', () => {
|
||||
});
|
||||
|
||||
it('hides the dialog', async () => {
|
||||
render();
|
||||
const { user } = render(<ReleasesPage />);
|
||||
const newReleaseButton = screen.getByRole('button', { name: 'New release' });
|
||||
await user.click(newReleaseButton);
|
||||
|
||||
@ -65,7 +47,7 @@ describe('Releases home page', () => {
|
||||
});
|
||||
|
||||
it('enables the submit button when there is content in the input', async () => {
|
||||
render();
|
||||
const { user } = render(<ReleasesPage />);
|
||||
const newReleaseButton = screen.getByRole('button', { name: 'New release' });
|
||||
await user.click(newReleaseButton);
|
||||
|
||||
|
@ -3,8 +3,16 @@
|
||||
"pages.Releases.title": "Releases",
|
||||
"pages.Releases.header-subtitle": "{number, plural, =0 {No releases} one {# release} other {# releases}}",
|
||||
"header.actions.add-release": "New Release",
|
||||
"header.actions.refresh": "Refresh",
|
||||
"header.actions.release": "Release",
|
||||
"header.actions.open-release-actions": "Release actions",
|
||||
"header.actions.edit": "Edit",
|
||||
"header.actions.delete": "Delete",
|
||||
"header.actions.created": "Created",
|
||||
"header.actions.created.description": "{number, plural, =0 {# days} one {# day} other {# days}} ago by {user}",
|
||||
"modal.release-created-notification-success": "Release created",
|
||||
"modal.add-release-title": "New Release",
|
||||
"modal.form.input.label.release-name": "Name",
|
||||
"modal.form.button.submit": "Continue"
|
||||
"modal.form.button.submit": "Continue",
|
||||
"pages.Details.header-subtitle": "{number, plural, =0 {No entries} one {# entry} other {# entries}}"
|
||||
}
|
||||
|
61
packages/core/content-releases/admin/src/utils/data.ts
Normal file
61
packages/core/content-releases/admin/src/utils/data.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import { getFetchClient } from '@strapi/helper-plugin';
|
||||
|
||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||
|
||||
export interface QueryArguments<TSend> {
|
||||
url: string;
|
||||
method: 'PUT' | 'GET' | 'POST' | 'DELETE';
|
||||
data?: TSend;
|
||||
config?: AxiosRequestConfig<TSend>;
|
||||
}
|
||||
|
||||
const axiosBaseQuery = async <TData = any, TSend = any>({
|
||||
url,
|
||||
method,
|
||||
data,
|
||||
config,
|
||||
}: QueryArguments<TSend>) => {
|
||||
try {
|
||||
const { get, post, del, put } = getFetchClient();
|
||||
|
||||
if (method === 'POST') {
|
||||
const res = await post<TData, AxiosResponse<TData>, TSend>(url, data, config);
|
||||
return res;
|
||||
}
|
||||
if (method === 'DELETE') {
|
||||
const res = await del<TData, AxiosResponse<TData>, TSend>(url, config);
|
||||
return res;
|
||||
}
|
||||
if (method === 'PUT') {
|
||||
const res = await put<TData, AxiosResponse<TData>, TSend>(url, data, config);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default is GET.
|
||||
*/
|
||||
const res = await get<TData, AxiosResponse<TData>, TSend>(url, config);
|
||||
return res;
|
||||
} catch (error) {
|
||||
const err = error as AxiosError;
|
||||
|
||||
/**
|
||||
* This format mimics what we want from an AxiosError which is what the
|
||||
* rest of the app works with, except this format is "serializable" since
|
||||
* it goes into the redux store.
|
||||
*
|
||||
* NOTE – passing the whole response will highlight this "serializability" issue.
|
||||
*/
|
||||
return {
|
||||
error: {
|
||||
status: err.response?.status,
|
||||
code: err.code,
|
||||
response: {
|
||||
data: err.response?.data,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export { axiosBaseQuery };
|
19
packages/core/content-releases/admin/src/utils/errors.ts
Normal file
19
packages/core/content-releases/admin/src/utils/errors.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
/**
|
||||
* This asserts the errors from redux-toolkit-query are
|
||||
* axios errors so we can pass them to our utility functions
|
||||
* to correctly render error messages.
|
||||
*/
|
||||
const isErrorAxiosError = (err: unknown): err is AxiosError<{ error: any }> => {
|
||||
return (
|
||||
typeof err === 'object' &&
|
||||
err !== null &&
|
||||
'response' in err &&
|
||||
typeof err.response === 'object' &&
|
||||
err.response !== null &&
|
||||
'data' in err.response
|
||||
);
|
||||
};
|
||||
|
||||
export { isErrorAxiosError };
|
4
packages/core/content-releases/admin/tests/server.ts
Normal file
4
packages/core/content-releases/admin/tests/server.ts
Normal file
@ -0,0 +1,4 @@
|
||||
// import { rest } from 'msw';
|
||||
import { setupServer } from 'msw/node';
|
||||
|
||||
export const server = setupServer(...[]);
|
13
packages/core/content-releases/admin/tests/setup.ts
Normal file
13
packages/core/content-releases/admin/tests/setup.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { server } from './server';
|
||||
|
||||
beforeAll(() => {
|
||||
server.listen();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
server.close();
|
||||
});
|
23
packages/core/content-releases/admin/tests/store.ts
Normal file
23
packages/core/content-releases/admin/tests/store.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { fixtures } from '@strapi/admin-test-utils';
|
||||
|
||||
/**
|
||||
* This is for the redux store in `utils`.
|
||||
* The more we adopt it, the bigger it will get – which is okay.
|
||||
*/
|
||||
const initialState = {
|
||||
admin_app: { permissions: fixtures.permissions.app },
|
||||
rbacProvider: {
|
||||
allPermissions: [
|
||||
...fixtures.permissions.allPermissions,
|
||||
{
|
||||
id: 314,
|
||||
action: 'admin::users.read',
|
||||
subject: null,
|
||||
properties: {},
|
||||
conditions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export { initialState };
|
117
packages/core/content-releases/admin/tests/utils.tsx
Normal file
117
packages/core/content-releases/admin/tests/utils.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
/* eslint-disable check-file/filename-naming-convention */
|
||||
import * as React from 'react';
|
||||
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { fixtures } from '@strapi/admin-test-utils';
|
||||
import { DesignSystemProvider } from '@strapi/design-system';
|
||||
import { NotificationsProvider, Permission, RBACContext } from '@strapi/helper-plugin';
|
||||
import {
|
||||
renderHook as renderHookRTL,
|
||||
render as renderRTL,
|
||||
waitFor,
|
||||
RenderOptions as RTLRenderOptions,
|
||||
RenderResult,
|
||||
act,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { IntlProvider } from 'react-intl';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter, MemoryRouterProps } from 'react-router-dom';
|
||||
|
||||
import { releaseApi } from '../src/modules/releaseSlice';
|
||||
|
||||
import { server } from './server';
|
||||
import { initialState } from './store';
|
||||
|
||||
interface ProvidersProps {
|
||||
children: React.ReactNode;
|
||||
initialEntries?: MemoryRouterProps['initialEntries'];
|
||||
}
|
||||
|
||||
const Providers = ({ children, initialEntries }: ProvidersProps) => {
|
||||
const store = configureStore({
|
||||
preloadedState: initialState,
|
||||
reducer: {
|
||||
[releaseApi.reducerPath]: releaseApi.reducer,
|
||||
admin_app: (state = initialState) => state,
|
||||
rbacProvider: (state = initialState) => state,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(releaseApi.middleware),
|
||||
});
|
||||
|
||||
// en is the default locale of the admin app.
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<MemoryRouter initialEntries={initialEntries}>
|
||||
<DesignSystemProvider locale="en">
|
||||
<IntlProvider locale="en" messages={{}} textComponent="span">
|
||||
<NotificationsProvider>
|
||||
<RBACContext.Provider
|
||||
value={{
|
||||
refetchPermissions: jest.fn(),
|
||||
allPermissions: [
|
||||
...fixtures.permissions.allPermissions,
|
||||
{
|
||||
id: 314,
|
||||
action: 'admin::users.read',
|
||||
subject: null,
|
||||
properties: {},
|
||||
conditions: [],
|
||||
actionParameters: {},
|
||||
},
|
||||
] as Permission[],
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</RBACContext.Provider>
|
||||
</NotificationsProvider>
|
||||
</IntlProvider>
|
||||
</DesignSystemProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/jsx-no-useless-fragment
|
||||
const fallbackWrapper = ({ children }: { children: React.ReactNode }) => <>{children}</>;
|
||||
|
||||
export interface RenderOptions {
|
||||
renderOptions?: RTLRenderOptions;
|
||||
userEventOptions?: Parameters<typeof userEvent.setup>[0];
|
||||
initialEntries?: MemoryRouterProps['initialEntries'];
|
||||
}
|
||||
|
||||
const render = (
|
||||
ui: React.ReactElement,
|
||||
{ renderOptions, userEventOptions, initialEntries }: RenderOptions = {}
|
||||
): RenderResult & { user: ReturnType<typeof userEvent.setup> } => {
|
||||
const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = renderOptions ?? {};
|
||||
|
||||
return {
|
||||
...renderRTL(ui, {
|
||||
wrapper: ({ children }) => (
|
||||
<Providers initialEntries={initialEntries}>
|
||||
<Wrapper>{children}</Wrapper>
|
||||
</Providers>
|
||||
),
|
||||
...restOptions,
|
||||
}),
|
||||
user: userEvent.setup(userEventOptions),
|
||||
};
|
||||
};
|
||||
|
||||
const renderHook: typeof renderHookRTL = (hook, options) => {
|
||||
const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = options ?? {};
|
||||
|
||||
return renderHookRTL(hook, {
|
||||
wrapper: ({ children }) => (
|
||||
<Providers>
|
||||
<Wrapper>{children}</Wrapper>
|
||||
</Providers>
|
||||
),
|
||||
...restOptions,
|
||||
});
|
||||
};
|
||||
|
||||
export { render, renderHook, waitFor, act, screen, server };
|
9
packages/core/content-releases/admin/tsconfig.build.json
Normal file
9
packages/core/content-releases/admin/tsconfig.build.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "./admin/tsconfig.json",
|
||||
"include": ["./admin/src", "./admin/custom.d.ts", "./shared"],
|
||||
"exclude": ["tests", "**/*.test.*"],
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
{
|
||||
"extends": "tsconfig/client.json",
|
||||
"include": [
|
||||
"src",
|
||||
"custom.d.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"rootDir": "../",
|
||||
}
|
||||
"paths": {
|
||||
"@tests/*": ["./tests/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "../shared", "tests", "custom.d.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
|
@ -3,4 +3,8 @@
|
||||
module.exports = {
|
||||
preset: '../../../jest-preset.front.js',
|
||||
displayName: 'Core Content Releases',
|
||||
moduleNameMapper: {
|
||||
'^@tests/(.*)$': '<rootDir>/admin/tests/$1',
|
||||
},
|
||||
setupFilesAfterEnv: ['./admin/tests/setup.ts'],
|
||||
};
|
||||
|
@ -40,21 +40,14 @@
|
||||
"./dist",
|
||||
"strapi-server.js"
|
||||
],
|
||||
"strapi": {
|
||||
"name": "content-releases",
|
||||
"description": "Organize and release content",
|
||||
"kind": "plugin",
|
||||
"displayName": "Releases",
|
||||
"required": true
|
||||
},
|
||||
"scripts": {
|
||||
"build": "pack-up build",
|
||||
"clean": "run -T rimraf ./dist",
|
||||
"lint": "run -T eslint .",
|
||||
"prepublishOnly": "yarn clean && yarn build",
|
||||
"test:front": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js",
|
||||
"test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll",
|
||||
"test:front:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js",
|
||||
"test:front:watch": "run -T cross-env IS_EE=true jest --config ./jest.config.front.js --watchAll",
|
||||
"test:front:watch:ce": "run -T cross-env IS_EE=false jest --config ./jest.config.front.js --watchAll",
|
||||
"test:ts:front": "run -T tsc -p admin/tsconfig.json",
|
||||
"test:unit": "run -T jest",
|
||||
@ -68,11 +61,14 @@
|
||||
"@strapi/icons": "1.13.0",
|
||||
"@strapi/types": "workspace:*",
|
||||
"@strapi/utils": "4.15.4",
|
||||
"axios": "1.6.0",
|
||||
"formik": "2.4.0",
|
||||
"react-intl": "6.4.1",
|
||||
"react-redux": "8.1.1",
|
||||
"yup": "0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@strapi/admin-test-utils": "4.15.4",
|
||||
"@strapi/pack-up": "workspace:*",
|
||||
"@strapi/strapi": "4.15.4",
|
||||
"@testing-library/react": "14.0.0",
|
||||
@ -80,6 +76,7 @@
|
||||
"@types/koa": "2.13.4",
|
||||
"@types/styled-components": "5.1.26",
|
||||
"koa": "2.13.4",
|
||||
"msw": "1.3.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "5.3.4",
|
||||
@ -101,5 +98,12 @@
|
||||
"implicitDependencies": [
|
||||
"!@strapi/strapi"
|
||||
]
|
||||
},
|
||||
"strapi": {
|
||||
"name": "content-releases",
|
||||
"description": "Organize and release content",
|
||||
"kind": "plugin",
|
||||
"displayName": "Releases",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
|
@ -7,6 +7,7 @@ export default defineConfig({
|
||||
import: './dist/admin/index.mjs',
|
||||
require: './dist/admin/index.js',
|
||||
types: './dist/admin/src/index.d.ts',
|
||||
tsconfig: './admin/tsconfig.build.json',
|
||||
runtime: 'web',
|
||||
},
|
||||
{
|
||||
@ -14,6 +15,7 @@ export default defineConfig({
|
||||
import: './dist/server/index.mjs',
|
||||
require: './dist/server/index.js',
|
||||
types: './dist/server/src/index.d.ts',
|
||||
tsconfig: './server/tsconfig.build.json',
|
||||
runtime: 'node',
|
||||
},
|
||||
],
|
||||
|
48
packages/core/content-releases/shared/contracts/releases.ts
Normal file
48
packages/core/content-releases/shared/contracts/releases.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Entity as StrapiEntity } from '@strapi/types';
|
||||
import { errors } from '@strapi/utils';
|
||||
|
||||
export interface Entity {
|
||||
id: StrapiEntity.ID;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Release extends Entity {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /content-releases - Create a single release
|
||||
*/
|
||||
export declare namespace CreateRelease {
|
||||
export interface Request {
|
||||
query: {};
|
||||
body: Omit<Release, keyof Entity>;
|
||||
}
|
||||
|
||||
export interface Response {
|
||||
data: Release;
|
||||
/**
|
||||
* TODO: check if we also could recieve errors.YupValidationError
|
||||
*/
|
||||
error?: errors.ApplicationError | errors.YupValidationError | errors.UnauthorizedError;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /content-releases - Get all the release
|
||||
*/
|
||||
export declare namespace GetAllReleases {
|
||||
export interface Request {
|
||||
query: {};
|
||||
body: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: Validate this with BE
|
||||
*/
|
||||
export interface Response {
|
||||
data: Release[];
|
||||
error?: errors.ApplicationError;
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "tsconfig/client.json",
|
||||
"include": [
|
||||
"./admin",
|
||||
"./server"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"declarationDir": "./dist",
|
||||
"outDir": "./dist"
|
||||
}
|
||||
}
|
@ -8764,6 +8764,7 @@ __metadata:
|
||||
resolution: "@strapi/content-releases@workspace:packages/core/content-releases"
|
||||
dependencies:
|
||||
"@reduxjs/toolkit": "npm:1.9.7"
|
||||
"@strapi/admin-test-utils": "npm:4.15.4"
|
||||
"@strapi/design-system": "npm:1.13.1"
|
||||
"@strapi/helper-plugin": "npm:4.15.4"
|
||||
"@strapi/icons": "npm:1.13.0"
|
||||
@ -8775,11 +8776,14 @@ __metadata:
|
||||
"@testing-library/user-event": "npm:14.4.3"
|
||||
"@types/koa": "npm:2.13.4"
|
||||
"@types/styled-components": "npm:5.1.26"
|
||||
axios: "npm:1.6.0"
|
||||
formik: "npm:2.4.0"
|
||||
koa: "npm:2.13.4"
|
||||
msw: "npm:1.3.0"
|
||||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
react-intl: "npm:6.4.1"
|
||||
react-redux: "npm:8.1.1"
|
||||
react-router-dom: "npm:5.3.4"
|
||||
styled-components: "npm:5.3.3"
|
||||
typescript: "npm:5.2.2"
|
||||
|
Loading…
x
Reference in New Issue
Block a user