Merge branch 'develop' into v5/main

This commit is contained in:
Josh 2024-04-02 10:11:20 +01:00
commit 660b779c4c
37 changed files with 5449 additions and 3782 deletions

View File

@ -46,7 +46,7 @@ The Strapi core team will review your pull request and either merge it, request
## Contribution Prerequisites
- You have [Node.js](https://nodejs.org/en/) at version >= v18 and <= v20 and [Yarn](https://yarnpkg.com/en/) at v1.2.0+ installed.
- You have [Node.js](https://nodejs.org/en/) at version `>= v18 and <= v20` and [Yarn](https://yarnpkg.com/en/) at v1.2.0+ installed.
- You are familiar with [Git](https://git-scm.com).
**Before submitting your pull request** make sure the following requirements are fulfilled:
@ -101,7 +101,7 @@ Start the administration panel server for development:
```bash
cd ./packages/core/admin
yarn develop
yarn watch
```
The administration panel should now be available at http://localhost:4000/admin. Make sure the example application (step 4) is still running.

View File

@ -40,7 +40,7 @@
Strapi Community Edition is a free and open-source headless CMS enabling you to manage any content, anywhere.
- **Self-hosted or Cloud**: You can host and scale Strapi projects the way you want. You can save time by deploying to [Strapi Cloud](https://cloud.strapi.io/signups?source=github1) or deploy to the hosting platform you want\*\*: AWS, Azure, Google Cloud, DigitalOcean.
- **Modern Admin Pane**: Elegant, entirely customizable and a fully extensible admin panel.
- **Modern Admin Panel**: Elegant, entirely customizable and a fully extensible admin panel.
- **Multi-database support**: You can choose the database you prefer: PostgreSQL, MySQL, MariaDB, and SQLite.
- **Customizable**: You can quickly build your logic by fully customizing APIs, routes, or plugins to fit your needs perfectly.
- **Blazing Fast and Robust**: Built on top of Node.js and TypeScript, Strapi delivers reliable and solid performance.

View File

@ -146,6 +146,48 @@ describe('Test Graphql API End to End', () => {
}));
});
test('List posts with GET', async () => {
const graphqlQueryGET = (body) => {
return rq({
url: '/graphql',
method: 'GET',
qs: body,
});
};
const res = await graphqlQueryGET({
query: /* GraphQL */ `
{
posts {
data {
id
attributes {
name
bigint
nullable
category
}
}
}
}
`,
});
const { body } = res;
expect(res.statusCode).toBe(200);
expect(body).toMatchObject({
data: {
posts: {
data: postsPayload.map((entry) => ({
id: expect.any(String),
attributes: omit('id', entry),
})),
},
},
});
});
test('List posts with limit', async () => {
const res = await graphqlQuery({
query: /* GraphQL */ `

View File

@ -0,0 +1,25 @@
'use strict';
// Helpers.
const { createStrapiInstance } = require('api-tests/strapi');
const request = require('supertest');
let strapi;
describe('Test Graphql Utils', () => {
beforeAll(async () => {
strapi = await createStrapiInstance();
});
afterAll(async () => {
await strapi.destroy();
});
test('Load Graphql playground', async () => {
const supertestAgent = request.agent(strapi.server.httpServer);
const res = await supertestAgent.get('/graphql').set('accept', 'text/html');
expect(res.statusCode).toBe(200);
expect(res.text).toContain('<title>GraphQL Playground</title>');
});
});

View File

@ -12,6 +12,6 @@ tags:
A source provider must implement the interface ISourceProvider found in `packages/core/data-transfer/types/providers.d.ts`.
In short, it provides a set of create{_stage_}ReadStream() methods for each stage that provide a Readable stream, which will retrieve its data (ideally from its own stream) and then perform a `stream.write(entity)` for each entity, link (relation), asset (file), configuration entity, or content type schema depending on the stage.
In short, it provides a set of `create{_stage_}ReadStream()` methods for each stage that provide a Readable stream, which will retrieve its data (ideally from its own stream) and then perform a `stream.write(entity)` for each entity, link (relation), asset (file), configuration entity, or content type schema depending on the stage.
When each stage's stream has finished sending all the data, the stream must be closed before the transfer engine will continue to the next stage.

View File

@ -12,4 +12,4 @@ tags:
A destination provider must implement the interface IDestinationProvider found in `packages/core/data-transfer/types/providers.d.ts`.
In short, it provides a set of create{_stage_}WriteStream() methods for each stage that provide a Writable stream, which will be passed each entity, link (relation), asset (file), configuration entity, or content type schema (depending on the stage) piped from the Readable source provider stream.
In short, it provides a set of `create{_stage_}WriteStream()` methods for each stage that provide a Writable stream, which will be passed each entity, link (relation), asset (file), configuration entity, or content type schema (depending on the stage) piped from the Readable source provider stream.

View File

@ -27,20 +27,20 @@ We store order values for all type of relations, except for:
- Polymorphic relations (too complicated to implement).
- One to one relations (as there is only one relation per pair)
### Many to many (Addresses <-> Categories)
### Many to many (Addresses &lt;-&gt; Categories)
<img src="/img/database/m2m-example.png" alt="many to many relation" />
- `category_order` is the order value of the categories relations in an address entity.
- `address_order` is the order value of the addresses relations in a category entity.
### One to one (Kitchensinks <-> Tags)
### One to one (Kitchensinks &lt;-&gt; Tags)
<img src="/img/database/o2o-example.png" alt="one to one relation" />
- there is no `order` fields as there is only one relation per pair.
### One way relation (Restaurants <-> Categories)
### One way relation (Restaurants &lt;-&gt; Categories)
Where a restaurant has many categories:
@ -108,7 +108,7 @@ From the `connect` array:
- If an **id** was **already in this array, remove the previous one**
- **Grouping by the order value**, and ignoring init relations
- Recalculate order values for each group, so there are no repeated numbers & they keep the same order.
- Example : [ {id: 5 , order: 1.5}, {id: 3, order: 1.5 } ] → [ {id: 5 , order: 1.33}, {id: 3, order: 1.66 } ]
- Example : `[ {id: 5 , order: 1.5}, {id: 3, order: 1.5 } ]``[ {id: 5 , order: 1.33}, {id: 3, order: 1.66 } ]`
- **Insert values in the database**
- **Update database order based on their order position.** (using ROW_NUMBER() clause)

View File

@ -1,8 +1,9 @@
// @ts-check
// Note: type annotations allow type checking and IDEs autocompletion
const path = require('path');
const lightCodeTheme = require('prism-react-renderer/themes/github');
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
const {
themes: { github: lightCodeTheme, dracula: darkCodeTheme },
} = require('prism-react-renderer');
/** @type {import('@docusaurus/types').Config} */
const config = {

View File

@ -26,16 +26,16 @@
]
},
"dependencies": {
"@docusaurus/core": "2.2.0",
"@docusaurus/preset-classic": "2.2.0",
"@mdx-js/react": "^1.6.22",
"@docusaurus/core": "3.1.1",
"@docusaurus/preset-classic": "3.1.1",
"@mdx-js/react": "^3.0.0",
"clsx": "^1.1.1",
"prism-react-renderer": "^1.3.3",
"react": "^17.0.2",
"react-dom": "^17.0.2"
"prism-react-renderer": "^2.1.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "2.2.0",
"@docusaurus/module-type-aliases": "3.1.1",
"docusaurus-plugin-typedoc": "0.22.0",
"typedoc": "0.25.9",
"typedoc-plugin-markdown": "3.17.1",

File diff suppressed because it is too large Load Diff

View File

@ -98,7 +98,6 @@ test.describe('Sign Up', () => {
}) => {
await page.getByRole('button', { name: "Let's start" }).click();
await page.waitForURL('**/admin');
await expect(page).toHaveTitle('Homepage | Strapi');
});
});

View File

@ -91,4 +91,35 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
await page.getByRole('link', { name: 'Releases' }).click();
await expect(page.getByRole('link', { name: `${newReleaseName}` })).toBeVisible();
});
test('A user should be able to perform bulk release on entries', async ({ page }) => {
await page.getByRole('link', { name: 'Content Manager' }).click();
await expect(page).toHaveTitle('Content Manager');
await expect(page.getByRole('heading', { name: 'Article' })).toBeVisible();
const publishedItems = page.getByRole('gridcell', { name: 'published' });
expect(publishedItems).toHaveCount(2);
const checkbox = page.getByRole('checkbox', { name: 'Select all entries' });
// Select all entries to release
await checkbox.check();
const addToRelease = page.getByRole('button', { name: 'add to release' });
await addToRelease.click();
// Wait for the add to release dialog to appear
await page
.getByRole('combobox', {
name: 'Select a release',
})
.click();
await page.getByRole('option', { name: 'Trent Crimm: The Independent' }).click();
const unpublishButton = page.getByText('unpublish', { exact: true });
await unpublishButton.click();
await page.getByText('continue').click();
await page.getByText(/Successfully added to release./).waitFor({
state: 'visible',
timeout: 5000,
});
});
});

View File

@ -155,7 +155,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
to: '/settings/transfer-tokens?sort=name:ASC',
id: 'transfer-tokens',
},
// If the Enterprise feature is not enabled and if the config doesn't disable it, we promote the Enterprise feature by displaying them in the settings menu.
// If the Enterprise/Cloud feature is not enabled and if the config doesn't disable it, we promote the Enterprise/Cloud feature by displaying them in the settings menu.
// Disable this by adding "promoteEE: false" to your `./config/admin.js` file
...(!window.strapi.features.isEnabled(window.strapi.features.SSO) &&
window.strapi?.flags?.promoteEE
@ -163,7 +163,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
{
intlLabel: { id: 'Settings.sso.title', defaultMessage: 'Single Sign-On' },
to: '/settings/purchase-single-sign-on',
id: 'sso',
id: 'sso-purchase-page',
lockIcon: true,
},
]
@ -178,7 +178,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
defaultMessage: 'Review Workflows',
},
to: '/settings/purchase-review-workflows',
id: 'review-workflows',
id: 'review-workflows-purchase-page',
lockIcon: true,
},
]
@ -203,7 +203,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
{
intlLabel: { id: 'global.auditLogs', defaultMessage: 'Audit Logs' },
to: '/settings/purchase-audit-logs',
id: 'auditLogs',
id: 'auditLogs-purchase-page',
lockIcon: true,
},
]

View File

@ -22,7 +22,7 @@ const PurchaseAuditLogs = () => {
content={formatMessage({
id: 'Settings.permissions.auditLogs.not-available',
defaultMessage:
'Audit Logs is only available as part of the Enterprise Edition. Upgrade to get a searchable and filterable display of all activities.',
'Audit Logs is only available as part of a paid plan. Upgrade to get a searchable and filterable display of all activities.',
})}
action={
<LinkButton

View File

@ -25,7 +25,7 @@ const PurchaseSingleSignOn = () => {
content={formatMessage({
id: 'Settings.sso.not-available',
defaultMessage:
'SSO is only available as part of the Enterprise Edition. Upgrade to configure additional sign-in & sign-up methods for your administration panel.',
'SSO is only available as part of a paid plan. Upgrade to configure additional sign-in & sign-up methods for your administration panel.',
})}
action={
<LinkButton

View File

@ -162,7 +162,7 @@
"Settings.permissions.auditLogs.entry.update": "Update entry{model, select, undefined {} other { ({model})}}",
"Settings.permissions.auditLogs.filters.combobox.aria-label": "Search and select an option to filter",
"Settings.permissions.auditLogs.listview.header.subtitle": "Logs of all the activities that happened in your environment",
"Settings.permissions.auditLogs.not-available": "Audit Logs is only available as part of the Enterprise Edition. Upgrade to get a searchable and filterable display of all activities.",
"Settings.permissions.auditLogs.not-available": "Audit Logs is only available as part of a paid plan. Upgrade to get a searchable and filterable display of all activities.",
"Settings.permissions.auditLogs.media.create": "Create media",
"Settings.permissions.auditLogs.media.delete": "Delete media",
"Settings.permissions.auditLogs.media.update": "Update media",
@ -249,7 +249,7 @@
"Settings.sso.form.registration.description": "Create new user on SSO login if no account exists",
"Settings.sso.form.registration.label": "Auto-registration",
"Settings.sso.title": "Single Sign-On",
"Settings.sso.not-available": "SSO is only available as part of the Enterprise Edition. Upgrade to configure additional sign-in & sign-up methods for your administration panel.",
"Settings.sso.not-available": "SSO is only available as part of a paid plan. Upgrade to configure additional sign-in & sign-up methods for your administration panel.",
"Settings.tokens.Button.cancel": "Cancel",
"Settings.tokens.Button.regenerate": "Regenerate",
"Settings.tokens.ListView.headers.createdAt": "Created at",

View File

@ -14,7 +14,6 @@ import {
DialogFooter,
DialogProps,
Flex,
ModalBody,
ModalHeader,
ModalLayout,
Typography,
@ -69,8 +68,7 @@ interface NotificationOptions {
interface ModalOptions {
type: 'modal';
title: string;
content: React.ReactNode;
footer: React.ComponentType<{ onClose: () => void }> | React.ReactNode;
content: React.ComponentType<{ onClose: () => void }>;
onClose?: () => void;
}
@ -261,8 +259,7 @@ const BulkActionModal = ({
isOpen,
title,
onClose,
footer: Footer,
content,
content: Content,
onModalClose,
}: BulkActionModalProps) => {
const id = React.useId();
@ -286,8 +283,7 @@ const BulkActionModal = ({
{title}
</Typography>
</ModalHeader>
<ModalBody>{content}</ModalBody>
<>{typeof Footer === 'function' ? <Footer onClose={handleClose} /> : Footer}</>
<Content onClose={handleClose} />
</ModalLayout>
);
};

View File

@ -11,6 +11,7 @@ import {
Box,
Button,
Typography,
ModalBody,
ModalFooter,
IconButton,
Flex,
@ -35,7 +36,7 @@ import { getTranslation } from '../../../../utils/translations';
import { getInnerErrors, createYupSchema } from '../../../../utils/validation';
// import { useAllowedActions } from '../../hooks/useAllowedActions';
import { ConfirmDialogPublishAll } from './ConfirmBulkActionDialog';
import { ConfirmDialogPublishAll, ConfirmDialogPublishAllProps } from './ConfirmBulkActionDialog';
import type { BulkActionComponent } from '../../../../content-manager';
import type { Data } from '@strapi/types';
@ -248,9 +249,6 @@ const SelectedEntriesModalContent = ({
// setEntriesToFetch,
// setSelectedListViewEntries,
validationErrors = {},
isDialogOpen,
setIsDialogOpen,
setIsPublishModalBtnDisabled,
}: SelectedEntriesModalContentProps) => {
const { formatMessage } = useIntl();
const {
@ -260,7 +258,6 @@ const SelectedEntriesModalContent = ({
isLoading,
} = useTable('SelectedEntriesModal', (state) => state);
const [rowsToDisplay, setRowsToDisplay] = React.useState<Array<TableRow>>([]);
const [publishedCount, setPublishedCount] = React.useState(0);
const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler();
@ -280,11 +277,11 @@ const SelectedEntriesModalContent = ({
const selectedEntriesWithNoErrorsCount =
selectedEntries.length - selectedEntriesWithErrorsCount - selectedEntriesPublished;
const toggleDialog = () => setIsDialogOpen((prev) => !prev);
// const toggleDialog = () => setIsDialogOpen((prev) => !prev);
const [publishManyDocuments, { isLoading: isSubmittingForm }] = usePublishManyDocumentsMutation();
const handleConfirmBulkPublish = async () => {
toggleDialog();
// toggleDialog();
try {
// @ts-expect-error TODO: this still expects Entity.ID instead of Document.ID
@ -377,19 +374,7 @@ const SelectedEntriesModalContent = ({
// Update the rows to display
setRowsToDisplay(rows);
}
}, [rows, setRowsToDisplay]);
React.useEffect(() => {
if (
selectedEntries.length === 0 ||
selectedEntries.length === selectedEntriesWithErrorsCount ||
isLoading
) {
setIsPublishModalBtnDisabled(true);
} else {
setIsPublishModalBtnDisabled(false);
}
}, [isLoading, selectedEntries, selectedEntriesWithErrorsCount, setIsPublishModalBtnDisabled]);
}, [rows]);
return (
<>
@ -402,12 +387,12 @@ const SelectedEntriesModalContent = ({
validationErrors={validationErrors}
/>
</Box>
<ConfirmDialogPublishAll
isOpen={isDialogOpen}
onToggleDialog={toggleDialog}
{/* <ConfirmDialogPublishAll
// isOpen={isDialogOpen}
// onToggleDialog={toggleDialog}
isConfirmButtonLoading={isSubmittingForm}
onConfirm={handleConfirmBulkPublish}
/>
/> */}
</>
);
};
@ -416,158 +401,163 @@ const SelectedEntriesModalContent = ({
* PublishAction
* -----------------------------------------------------------------------------------------------*/
const PublishAction: BulkActionComponent = ({ model: slug }) => {
const { formatMessage } = useIntl();
const PublishAction: BulkActionComponent = () => {
/**
* TODO: fix this in V5 with the rest of bulk actions
*/
return null;
const { selectedRows: selectedListViewEntries, selectRow: setSelectedListViewEntries } = useTable(
'SelectedEntriesModal',
(state) => state
);
// const { formatMessage } = useIntl();
const { model, schema, components, isLoading: isLoadingDoc } = useDoc();
// const selectedEntriesObjects = list.filter((entry) => selectedEntries.includes(entry.id));
// const hasPublishPermission = useAllowedActions(slug).canPublish;
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
const queryClient = useQueryClient();
// const showPublishButton =
// hasPublishPermission && selectedEntriesObjects.some((entry) => !entry.publishedAt);
const [isPublishModalBtnDisabled, setIsPublishModalBtnDisabled] = React.useState(true);
// const { selectedRows: selectedListViewEntries, selectRow: setSelectedListViewEntries } = useTable(
// 'SelectedEntriesModal',
// (state) => state
// );
// The child table will update this value based on the entries that were published
const [entriesToFetch, setEntriesToFetch] = React.useState(selectedListViewEntries);
// We want to keep the selected entries order same as the list view
const [
{
query: { sort, plugins },
},
] = useQueryParams<{ sort?: string; plugins?: Record<string, any> }>();
// const { model, schema, components, isLoading: isLoadingDoc } = useDoc();
// // const selectedEntriesObjects = list.filter((entry) => selectedEntries.includes(entry.id));
// // const hasPublishPermission = useAllowedActions(slug).canPublish;
// const [isDialogOpen, setIsDialogOpen] = React.useState(false);
// const queryClient = useQueryClient();
// // const showPublishButton =
// // hasPublishPermission && selectedEntriesObjects.some((entry) => !entry.publishedAt);
// const [isPublishModalBtnDisabled, setIsPublishModalBtnDisabled] = React.useState(true);
const { data, isLoading, isFetching } = useGetAllDocumentsQuery(
{
model,
params: {
page: '1',
pageSize: entriesToFetch.length.toString(),
sort,
filters: {
id: {
$in: entriesToFetch,
},
},
locale: plugins?.i18n?.locale,
},
},
{
selectFromResult: ({ data, ...restRes }) => ({ data: data?.results ?? [], ...restRes }),
}
);
// // The child table will update this value based on the entries that were published
// const [entriesToFetch, setEntriesToFetch] = React.useState(selectedListViewEntries);
// // We want to keep the selected entries order same as the list view
// const [
// {
// query: { sort, plugins },
// },
// ] = useQueryParams<{ sort?: string; plugins?: Record<string, any> }>();
const { rows, validationErrors } = React.useMemo(() => {
if (data.length > 0 && schema) {
const validate = createYupSchema(schema.attributes, components);
const validationErrors: Record<Data.ID, Record<string, MessageDescriptor>> = {};
const rows = data.map((entry) => {
try {
validate.validateSync(entry, { abortEarly: false });
// const { data, isLoading, isFetching } = useGetAllDocumentsQuery(
// {
// model,
// params: {
// page: '1',
// pageSize: entriesToFetch.length.toString(),
// sort,
// filters: {
// id: {
// $in: entriesToFetch,
// },
// },
// locale: plugins?.i18n?.locale,
// },
// },
// {
// selectFromResult: ({ data, ...restRes }) => ({ data: data?.results ?? [], ...restRes }),
// }
// );
return entry;
} catch (e) {
if (e instanceof ValidationError) {
validationErrors[entry.id] = getInnerErrors(e);
}
// const { rows, validationErrors } = React.useMemo(() => {
// if (data.length > 0 && schema) {
// const validate = createYupSchema(schema.attributes, components);
// const validationErrors: Record<Data.ID, Record<string, MessageDescriptor>> = {};
// const rows = data.map((entry) => {
// try {
// validate.validateSync(entry, { abortEarly: false });
return entry;
}
});
// return entry;
// } catch (e) {
// if (e instanceof ValidationError) {
// validationErrors[entry.id] = getInnerErrors(e);
// }
return { rows, validationErrors };
}
// return entry;
// }
// });
return {
rows: [],
validationErrors: {},
};
}, [components, data, schema]);
// return { rows, validationErrors };
// }
const refetchList = () => {
queryClient.invalidateQueries(['content-manager', 'collection-types', slug]);
};
// return {
// rows: [],
// validationErrors: {},
// };
// }, [components, data, schema]);
// If all the entries are published, we want to refetch the list view
if (rows.length === 0) {
refetchList();
}
// const refetchList = () => {
// // queryClient.invalidateQueries(['content-manager', 'collection-types', slug]);
// };
// if (!showPublishButton) return null;
// // If all the entries are published, we want to refetch the list view
// if (rows.length === 0) {
// refetchList();
// }
return {
actionType: 'publish',
variant: 'tertiary',
label: formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' }),
dialog: {
type: 'modal',
title: formatMessage({
id: getTranslation('containers.ListPage.selectedEntriesModal.title'),
defaultMessage: 'Publish entries',
}),
content: (
<Table.Root
rows={rows}
defaultSelectedRows={selectedListViewEntries}
headers={TABLE_HEADERS}
isLoading={isLoading || isLoadingDoc || isFetching}
>
<SelectedEntriesModalContent
isDialogOpen={isDialogOpen}
setIsDialogOpen={setIsDialogOpen}
refetchList={refetchList}
setSelectedListViewEntries={setSelectedListViewEntries}
setEntriesToFetch={setEntriesToFetch}
validationErrors={validationErrors}
setIsPublishModalBtnDisabled={setIsPublishModalBtnDisabled}
/>
</Table.Root>
),
footer: ({ onClose }) => {
return (
<ModalFooter
startActions={
<Button
onClick={() => {
onClose();
refetchList();
}}
variant="tertiary"
>
{formatMessage({
id: 'app.components.Button.cancel',
defaultMessage: 'Cancel',
})}
</Button>
}
endActions={
<Flex gap={2}>
<Button /* onClick={() => refetchModalData()} */ variant="tertiary">
{formatMessage({ id: 'app.utils.refresh', defaultMessage: 'Refresh' })}
</Button>
<Button
onClick={() => setIsDialogOpen((prev) => !prev)}
disabled={isPublishModalBtnDisabled}
// TODO: in V5 when bulk actions are refactored, we should use the isLoading prop
// loading={bulkPublishMutation.isLoading}
>
{formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' })}
</Button>
</Flex>
}
/>
);
},
onClose: () => {
refetchList();
},
},
};
// // if (!showPublishButton) return null;
// return {
// actionType: 'publish',
// variant: 'tertiary',
// label: formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' }),
// dialog: {
// type: 'modal',
// title: formatMessage({
// id: getTranslation('containers.ListPage.selectedEntriesModal.title'),
// defaultMessage: 'Publish entries',
// }),
// content: (
// <Table.Root
// rows={rows}
// defaultSelectedRows={selectedListViewEntries}
// headers={TABLE_HEADERS}
// isLoading={isLoading || isLoadingDoc || isFetching}
// >
// <SelectedEntriesModalContent
// isDialogOpen={isDialogOpen}
// setIsDialogOpen={setIsDialogOpen}
// refetchList={refetchList}
// setSelectedListViewEntries={setSelectedListViewEntries}
// setEntriesToFetch={setEntriesToFetch}
// validationErrors={validationErrors}
// setIsPublishModalBtnDisabled={setIsPublishModalBtnDisabled}
// />
// </Table.Root>
// ),
// footer: ({ onClose }) => {
// return (
// <ModalFooter
// startActions={
// <Button
// onClick={() => {
// onClose();
// refetchList();
// }}
// variant="tertiary"
// >
// {formatMessage({
// id: 'app.components.Button.cancel',
// defaultMessage: 'Cancel',
// })}
// </Button>
// }
// endActions={
// <Flex gap={2}>
// <Button /* onClick={() => refetchModalData()} */ variant="tertiary">
// {formatMessage({ id: 'app.utils.refresh', defaultMessage: 'Refresh' })}
// </Button>
// <Button
// onClick={() => setIsDialogOpen((prev) => !prev)}
// disabled={isPublishModalBtnDisabled}
// // TODO: in V5 when bulk actions are refactored, we should use the isLoading prop
// // loading={bulkPublishMutation.isLoading}
// >
// {formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' })}
// </Button>
// </Flex>
// }
// />
// );
// },
// onClose: () => {
// refetchList();
// },
// },
// };
};
export { PublishAction, SelectedEntriesModalContent };

View File

@ -45,17 +45,17 @@ import type { UID } from '@strapi/types';
* AddActionToReleaseModal
* -----------------------------------------------------------------------------------------------*/
const RELEASE_ACTION_FORM_SCHEMA = yup.object().shape({
export const RELEASE_ACTION_FORM_SCHEMA = yup.object().shape({
type: yup.string().oneOf(['publish', 'unpublish']).required(),
releaseId: yup.string().required(),
});
interface FormValues {
export interface FormValues {
type: CreateReleaseAction.Request['body']['type'];
releaseId: CreateReleaseAction.Request['params']['releaseId'];
}
const INITIAL_VALUES = {
export const INITIAL_VALUES = {
type: 'publish',
releaseId: '',
} satisfies FormValues;
@ -66,7 +66,7 @@ interface AddActionToReleaseModalProps {
entryId: GetContentTypeEntryReleases.Request['query']['entryId'];
}
const NoReleases = () => {
export const NoReleases = () => {
const { formatMessage } = useIntl();
return (
<EmptyStateLayout

View File

@ -0,0 +1,240 @@
import * as React from 'react';
import {
useAPIErrorHandler,
useNotification,
useQueryParams,
useRBAC,
} from '@strapi/admin/strapi-admin';
import {
Box,
Button,
FieldLabel,
Flex,
SingleSelect,
SingleSelectOption,
ModalBody,
ModalFooter,
} from '@strapi/design-system';
import { UID } from '@strapi/types';
import { isAxiosError } from 'axios';
import { Formik, Form } from 'formik';
import { useIntl } from 'react-intl';
import { CreateManyReleaseActions } from '../../../shared/contracts/release-actions';
import { PERMISSIONS as releasePermissions } from '../constants';
import { useCreateManyReleaseActionsMutation, useGetReleasesQuery } from '../services/release';
import { type FormValues, INITIAL_VALUES, RELEASE_ACTION_FORM_SCHEMA } from './CMReleasesContainer';
import { NoReleases } from './CMReleasesContainer';
import { ReleaseActionOptions } from './ReleaseActionOptions';
import type { BulkActionComponent } from '@strapi/plugin-content-manager/strapi-admin';
const getContentPermissions = (subject: string) => {
const permissions = {
publish: [
{
action: 'plugin::content-manager.explorer.publish',
subject,
id: '',
actionParameters: {},
properties: {},
conditions: [],
},
],
};
return permissions;
};
const ReleaseAction: BulkActionComponent = ({ documentIds, model }) => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const { formatAPIError } = useAPIErrorHandler();
const [{ query }] = useQueryParams<{ plugins?: { i18n?: { locale?: string } } }>();
const contentPermissions = getContentPermissions(model);
const {
allowedActions: { canPublish },
} = useRBAC(contentPermissions);
const {
allowedActions: { canCreate },
} = useRBAC(releasePermissions);
// Get all the releases not published
const response = useGetReleasesQuery();
const releases = response.data?.data;
const [createManyReleaseActions, { isLoading }] = useCreateManyReleaseActionsMutation();
const handleSubmit = async (values: FormValues) => {
const locale = query.plugins?.i18n?.locale;
// @ts-expect-error this may not work because id needs to be an entity number not a document id (string)
const releaseActionEntries: CreateManyReleaseActions.Request['body'] = documentIds.map(
(id) => ({
type: values.type,
entry: {
contentType: model as UID.ContentType,
id,
locale,
},
})
);
const response = await createManyReleaseActions({
body: releaseActionEntries,
params: { releaseId: values.releaseId },
});
if ('data' in response) {
// Handle success
const notificationMessage = formatMessage(
{
id: 'content-releases.content-manager-list-view.add-to-release.notification.success.message',
defaultMessage:
'{entriesAlreadyInRelease} out of {totalEntries} entries were already in the release.',
},
{
entriesAlreadyInRelease: response.data.meta.entriesAlreadyInRelease,
totalEntries: response.data.meta.totalEntries,
}
);
const notification = {
type: 'success' as const,
title: formatMessage(
{
id: 'content-releases.content-manager-list-view.add-to-release.notification.success.title',
defaultMessage: 'Successfully added to release.',
},
{
entriesAlreadyInRelease: response.data.meta.entriesAlreadyInRelease,
totalEntries: response.data.meta.totalEntries,
}
),
message: response.data.meta.entriesAlreadyInRelease ? notificationMessage : '',
};
toggleNotification(notification);
return true;
}
if ('error' in response) {
if (isAxiosError(response.error)) {
// Handle axios error
toggleNotification({
type: 'warning',
message: formatAPIError(response.error),
});
} else {
// Handle generic error
toggleNotification({
type: 'warning',
message: formatMessage({ id: 'notification.error', defaultMessage: 'An error occurred' }),
});
}
}
};
if (!canCreate || !canPublish) return null;
return {
actionType: 'release',
variant: 'tertiary',
label: formatMessage({
id: 'content-manager-list-view.add-to-release',
defaultMessage: 'Add to Release',
}),
dialog: {
type: 'modal',
title: formatMessage({
id: 'content-manager-list-view.add-to-release',
defaultMessage: 'Add to Release',
}),
content: ({ onClose }) => {
return (
<Formik
onSubmit={async (values) => {
const data = await handleSubmit(values);
if (data) {
return onClose();
}
}}
validationSchema={RELEASE_ACTION_FORM_SCHEMA}
initialValues={INITIAL_VALUES}
>
{({ values, setFieldValue }) => (
<Form>
{releases?.length === 0 ? (
<NoReleases />
) : (
<ModalBody>
<Flex direction="column" alignItems="stretch" gap={2}>
<Box paddingBottom={6}>
<SingleSelect
required
label={formatMessage({
id: 'content-releases.content-manager-list-view.add-to-release.select-label',
defaultMessage: 'Select a release',
})}
placeholder={formatMessage({
id: 'content-releases.content-manager-list-view.add-to-release.select-placeholder',
defaultMessage: 'Select',
})}
onChange={(value) => setFieldValue('releaseId', value)}
value={values.releaseId}
>
{releases?.map((release) => (
<SingleSelectOption key={release.id} value={release.id}>
{release.name}
</SingleSelectOption>
))}
</SingleSelect>
</Box>
<FieldLabel>
{formatMessage({
id: 'content-releases.content-manager-list-view.add-to-release.action-type-label',
defaultMessage: 'What do you want to do with these entries?',
})}
</FieldLabel>
<ReleaseActionOptions
selected={values.type}
handleChange={(e) => setFieldValue('type', e.target.value)}
name="type"
/>
</Flex>
</ModalBody>
)}
<ModalFooter
startActions={
<Button onClick={onClose} variant="tertiary" name="cancel">
{formatMessage({
id: 'content-releases.content-manager-list-view.add-to-release.cancel-button',
defaultMessage: 'Cancel',
})}
</Button>
}
endActions={
/**
* TODO: Ideally we would use isValid from Formik to disable the button, however currently it always returns true
* for yup.string().required(), even when the value is falsy (including empty string)
*/
<Button type="submit" disabled={!values.releaseId} loading={isLoading}>
{formatMessage({
id: 'content-releases.content-manager-list-view.add-to-release.continue-button',
defaultMessage: 'Continue',
})}
</Button>
}
/>
</Form>
)}
</Formik>
);
},
},
};
};
export { ReleaseAction };

View File

@ -28,7 +28,7 @@ import { getTimezoneOffset } from '../utils/time';
export interface FormValues {
name: string;
date: Date | null;
date: string | null;
time: string;
timezone: string | null;
isScheduled?: boolean;
@ -62,9 +62,8 @@ export const ReleaseModal = ({
const getScheduledTimestamp = (values: FormValues) => {
const { date, time, timezone } = values;
if (!date || !time || !timezone) return null;
const formattedDate = parse(time, 'HH:mm', new Date(date));
const timezoneWithoutOffset = timezone.split('&')[1];
return zonedTimeToUtc(formattedDate, timezoneWithoutOffset);
return zonedTimeToUtc(`${date} ${time}`, timezoneWithoutOffset);
};
/**

View File

@ -1,12 +1,14 @@
import { PaperPlane } from '@strapi/icons';
import { CMReleasesContainer } from './components/CMReleasesContainer';
import { ReleaseAction } from './components/ReleaseAction';
import { PERMISSIONS } from './constants';
import { pluginId } from './pluginId';
import { releaseApi } from './services/release';
import { prefixPluginTranslations } from './utils/prefixPluginTranslations';
import type { StrapiApp } from '@strapi/admin/strapi-admin';
import type { BulkActionComponent } from '@strapi/plugin-content-manager/strapi-admin';
import type { Plugin } from '@strapi/types';
// eslint-disable-next-line import/no-default-export
@ -41,6 +43,15 @@ const admin: Plugin.Config.AdminInput = {
name: `${pluginId}-link`,
Component: CMReleasesContainer,
});
// @ts-expect-error plugins are not typed on the StrapiApp, fix this.
app.plugins['content-manager'].apis.addBulkAction((actions: BulkActionComponent[]) => {
// We want to add this action to just before the delete action all the time
const deleteActionIndex = actions.findIndex((action) => action.name === 'DeleteAction');
actions.splice(deleteActionIndex, 0, ReleaseAction);
return actions;
});
} else if (
!window.strapi.features.isEnabled('cms-content-releases') &&
window.strapi?.flags?.promoteEE

View File

@ -25,7 +25,7 @@ const PurchaseContentReleases = () => {
content={formatMessage({
id: 'content-releases.pages.PurchaseRelease.not-available',
defaultMessage:
'Releases is only available as part of the Enterprise Edition. Upgrade to create and manage releases.',
'Releases is only available as part of a paid plan. Upgrade to create and manage releases.',
})}
action={
<LinkButton

View File

@ -873,7 +873,7 @@ const ReleaseDetailsPage = () => {
const scheduledAt =
releaseData?.scheduledAt && timezone ? utcToZonedTime(releaseData.scheduledAt, timezone) : null;
// Just get the date and time to display without considering updated timezone time
const date = scheduledAt ? new Date(format(scheduledAt, 'yyyy-MM-dd')) : null;
const date = scheduledAt ? format(scheduledAt, 'yyyy-MM-dd') : null;
const time = scheduledAt ? format(scheduledAt, 'HH:mm') : '';
const handleEditRelease = async (values: FormValues) => {

View File

@ -37,7 +37,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
import styled from 'styled-components';
import { GetReleases, type Release } from '../../../shared/contracts/releases';
import { RelativeTime } from '../components/RelativeTime';
import { RelativeTime as BaseRelativeTime } from '../components/RelativeTime';
import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/axios';
@ -60,8 +60,11 @@ const LinkCard = styled(Link)`
display: block;
`;
const CapitalizeRelativeTime = styled(RelativeTime)`
text-transform: capitalize;
const RelativeTime = styled(BaseRelativeTime)`
display: inline-block;
&::first-letter {
text-transform: uppercase;
}
`;
const getBadgeProps = (status: Release['status']) => {
@ -138,7 +141,7 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
</Typography>
<Typography variant="pi" textColor="neutral600">
{scheduledAt ? (
<CapitalizeRelativeTime timestamp={new Date(scheduledAt)} />
<RelativeTime timestamp={new Date(scheduledAt)} />
) : (
formatMessage({
id: 'content-releases.pages.Releases.not-scheduled',

View File

@ -1,3 +1,5 @@
import { ReactNode } from 'react';
import { within } from '@testing-library/react';
import { render, server, screen } from '@tests/utils';
import { rest } from 'msw';

View File

@ -2,6 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react';
import {
CreateReleaseAction,
CreateManyReleaseActions,
DeleteReleaseAction,
} from '../../../shared/contracts/release-actions';
import { pluginId } from '../pluginId';
@ -185,6 +186,22 @@ const releaseApi = createApi({
{ type: 'ReleaseAction', id: 'LIST' },
],
}),
createManyReleaseActions: build.mutation<
CreateManyReleaseActions.Response,
CreateManyReleaseActions.Request
>({
query({ body, params }) {
return {
url: `/content-releases/${params.releaseId}/actions/bulk`,
method: 'POST',
data: body,
};
},
invalidatesTags: [
{ type: 'Release', id: 'LIST' },
{ type: 'ReleaseAction', id: 'LIST' },
],
}),
updateReleaseAction: build.mutation<
UpdateReleaseAction.Response,
UpdateReleaseAction.Request & { query: GetReleaseActions.Request['query'] } & {
@ -269,6 +286,7 @@ const {
useGetReleaseActionsQuery,
useCreateReleaseMutation,
useCreateReleaseActionMutation,
useCreateManyReleaseActionsMutation,
useUpdateReleaseMutation,
useUpdateReleaseActionMutation,
usePublishReleaseMutation,
@ -283,6 +301,7 @@ export {
useGetReleaseActionsQuery,
useCreateReleaseMutation,
useCreateReleaseActionMutation,
useCreateManyReleaseActionsMutation,
useUpdateReleaseMutation,
useUpdateReleaseActionMutation,
usePublishReleaseMutation,

View File

@ -15,6 +15,14 @@
"content-releases.content-manager-edit-view.edit-entry": "Edit entry",
"content-manager-edit-view.remove-from-release.notification.success": "Entry removed from release",
"content-manager-edit-view.release-action-menu": "Release action options",
"content-manager-list-view.add-to-release": "Add to release",
"content-manager-list-view.add-to-release.cancel-button": "Cancel",
"content-manager-list-view.add-to-release.continue-button": "Continue",
"content-manager-list-view.add-to-release.select-label": "Select a release",
"content-manager-list-view.add-to-release.select-placeholder": "Select",
"content-manager-list-view.add-to-release.action-type-label": "What do you want to do with these entries?",
"content-manager-list-view.add-to-release.notification.success.title": "Successfully added to release.",
"content-manager-list-view.add-to-release.notification.success.message": "{entriesAlreadyInRelease} out of {totalEntries} entries were already in the release.",
"content-manager.notification.entry-error": "Failed to get entry data",
"plugin.name": "Releases",
"pages.Releases.title": "Releases",
@ -23,7 +31,7 @@
"pages.Releases.max-limit-reached.message": "Upgrade to manage an unlimited number of releases.",
"pages.Releases.max-limit-reached.action": "Explore plans",
"pages.PurchaseRelease.subTitle": "Manage content updates and releases.",
"pages.PurchaseRelease.not-available": "Releases is only available as part of the Enterprise Edition. Upgrade to create and manage releases.",
"pages.PurchaseRelease.not-available": "Releases is only available as part of a paid plan. Upgrade to create and manage releases.",
"header.actions.add-release": "New Release",
"header.actions.refresh": "Refresh",
"header.actions.publish": "Publish",

View File

@ -1,12 +1,13 @@
import { Writable } from 'stream';
import type { Core, Struct, UID } from '@strapi/types';
import type { Core, UID } from '@strapi/types';
import { get, last } from 'lodash/fp';
import { last } from 'lodash/fp';
import { ProviderTransferError } from '../../../../../errors/providers';
import type { IEntity, Transaction } from '../../../../../../types';
import { json } from '../../../../../utils';
import * as queries from '../../../../queries';
import { resolveComponentUID } from '../../../../../utils/components';
interface IEntitiesRestoreStreamOptions {
strapi: Core.LoadedStrapi;
@ -18,7 +19,7 @@ interface IEntitiesRestoreStreamOptions {
transaction?: Transaction;
}
const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
export const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
const { strapi, updateMappingTable, transaction } = options;
const query = queries.entity.createEntityQuery(strapi);
@ -31,48 +32,6 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
const { create, getDeepPopulateComponentLikeQuery } = query(type);
const contentType = strapi.getModel(type);
let cType:
| Struct.ContentTypeSchema
| Struct.ComponentSchema
| ((...opts: any[]) => Struct.Schema) = contentType;
/**
* Resolve the component UID of an entity's attribute based
* on a given path (components & dynamic zones only)
*/
const resolveType = (paths: string[]): UID.Schema | undefined => {
let value: unknown = data;
for (const path of paths) {
value = get(path, value);
// Needed when the value of cType should be computed
// based on the next value (eg: dynamic zones)
if (typeof cType === 'function') {
cType = cType(value);
}
if (path in cType.attributes) {
const attribute = cType.attributes[path];
if (attribute.type === 'component') {
cType = strapi.getModel(attribute.component);
}
if (attribute.type === 'dynamiczone') {
cType = ({ __component }: { __component: UID.Component }) =>
strapi.getModel(__component);
}
}
}
if ('uid' in cType) {
return cType.uid;
}
return undefined;
};
try {
const created = await create({
data,
@ -88,8 +47,8 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
// For each difference found on an ID attribute,
// update the mapping the table accordingly
diffs.forEach((diff) => {
if (diff.kind === 'modified' && last(diff.path) === 'id') {
const target = resolveType(diff.path);
if (diff.kind === 'modified' && last(diff.path) === 'id' && 'kind' in contentType) {
const target = resolveComponentUID({ paths: diff.path, data, contentType, strapi });
// If no type is found for the given path, then ignore the diff
if (!target) {
@ -114,5 +73,3 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
},
});
};
export { createEntitiesWriteStream };

View File

@ -4,7 +4,7 @@ import * as webStream from 'stream/web';
import { stat, createReadStream, ReadStream } from 'fs-extra';
import type { Core } from '@strapi/types';
import type { IAsset } from '../../../../types';
import type { IAsset, IFile } from '../../../../types';
function getFileStream(
filepath: string,
@ -70,6 +70,28 @@ function getFileStats(
});
});
}
async function signFile(file: IFile) {
const { provider } = strapi.plugins.upload;
const { provider: providerName } = strapi.config.get('plugin.upload') as { provider: string };
const isPrivate = await provider.isPrivate();
if (file?.provider === providerName && isPrivate) {
const signUrl = async (file: IFile) => {
const signedUrl = await provider.getSignedUrl(file);
file.url = signedUrl.url;
};
// Sign the original file
await signUrl(file);
// Sign each file format
if (file.formats) {
for (const format of Object.keys(file.formats)) {
await signUrl(file.formats[format]);
}
}
}
}
/**
* Generate and consume assets streams in order to stream each file individually
*/
@ -85,6 +107,9 @@ export const createAssetsStream = (strapi: Core.LoadedStrapi): Duplex => {
for await (const file of stream) {
const isLocalProvider = file.provider === 'local';
if (!isLocalProvider) {
await signFile(file);
}
const filepath = isLocalProvider ? join(strapi.dirs.static.public, file.url) : file.url;
const stats = await getFileStats(filepath, strapi, isLocalProvider);
const stream = getFileStream(filepath, strapi, isLocalProvider);

View File

@ -0,0 +1,118 @@
import type { LoadedStrapi, Schema } from '@strapi/types';
import { resolveComponentUID } from '../components';
const baseContentType: Schema.ContentType = {
collectionName: 'test',
info: {
singularName: 'test',
pluralName: 'tests',
displayName: 'Test',
},
attributes: {
// To fill in the different tests
},
options: {
draftAndPublish: false,
},
kind: 'collectionType',
modelType: 'contentType',
modelName: 'user',
uid: 'api::test.test',
globalId: 'Test',
};
describe('resolveComponentUID', () => {
const uid = 'test.test';
it('should return the component UID when the path matches a repeatable component', () => {
const contentType: Schema.ContentType | Schema.Component = {
...baseContentType,
attributes: {
relsRepeatable: {
type: 'component',
repeatable: true,
component: uid,
},
},
};
const strapi = {
getModel: jest.fn().mockReturnValueOnce({
collectionName: 'components_test_rels_repeatables',
attributes: {
// doesn't matter
},
uid,
}),
} as unknown as LoadedStrapi;
const paths = ['relsRepeatable', '0', 'id'];
const data = {
relsRepeatable: [{ id: 1, title: 'test' }],
};
const expectedUID = resolveComponentUID({ paths, strapi, data, contentType });
expect(expectedUID).toEqual(uid);
});
it('should return the component UID when the path matches a single component', () => {
const contentType: Schema.ContentType | Schema.Component = {
...baseContentType,
attributes: {
rels: {
type: 'component',
repeatable: false,
component: uid,
},
},
};
const strapi = {
getModel: jest.fn().mockReturnValueOnce({
collectionName: 'components_test_rels',
attributes: {
// doesn't matter
},
uid,
}),
} as unknown as LoadedStrapi;
const paths = ['rels', 'id'];
const data = {
rels: { id: 1, title: 'test' },
};
const expectedUID = resolveComponentUID({ paths, strapi, data, contentType });
expect(expectedUID).toEqual(uid);
});
it('should return the component UID when the path matches a dynamic zone', () => {
const contentType: Schema.ContentType | Schema.Component = {
...baseContentType,
attributes: {
dz: {
type: 'dynamiczone',
components: [uid],
},
},
};
const strapi = {
getModel: jest.fn().mockReturnValueOnce({
collectionName: 'components_test_rels',
attributes: {
// doesn't matter
},
uid,
}),
} as unknown as LoadedStrapi;
const paths = ['dz', '0', 'id'];
const data = {
dz: [{ __component: 'test.rels', id: 1, title: 'test' }],
};
const expectedUID = resolveComponentUID({ paths, strapi, data, contentType });
expect(expectedUID).toEqual(uid);
});
});

View File

@ -1,8 +1,8 @@
import _ from 'lodash';
import { has, omit, pipe, assign } from 'lodash/fp';
import { get, has, omit, pipe, assign } from 'lodash/fp';
import { contentTypes as contentTypesUtils, async, errors } from '@strapi/utils';
import type { Modules, UID, Data, Utils, Schema } from '@strapi/types';
import type { Modules, UID, Data, Utils, Schema, Core } from '@strapi/types';
type LoadedComponents<TUID extends UID.Schema> = Data.Entity<
TUID,
@ -477,6 +477,55 @@ const deleteComponent = async <TUID extends UID.Component>(
await strapi.db.query(uid).delete({ where: { id: componentToDelete.id } });
};
/**
* Resolve the component UID of an entity's attribute based
* on a given path (components & dynamic zones only)
*/
const resolveComponentUID = ({
paths,
strapi,
data,
contentType,
}: {
paths: string[];
strapi: Core.LoadedStrapi;
data: any;
contentType: Schema.ContentType;
}): UID.Schema | undefined => {
let value: unknown = data;
let cType:
| Schema.ContentType
| Schema.Component
| ((...opts: any[]) => Schema.ContentType | Schema.Component) = contentType;
for (const path of paths) {
value = get(path, value);
// Needed when the value of cType should be computed
// based on the next value (eg: dynamic zones)
if (typeof cType === 'function') {
cType = cType(value);
}
if (path in cType.attributes) {
const attribute: Schema.Attribute.AnyAttribute = cType.attributes[path];
if (attribute.type === 'component') {
cType = strapi.getModel(attribute.component);
}
if (attribute.type === 'dynamiczone') {
cType = ({ __component }: { __component: UID.Component }) => strapi.getModel(__component);
}
}
}
if ('uid' in cType) {
return cType.uid;
}
return undefined;
};
export {
omitComponentData,
getComponents,
@ -484,4 +533,5 @@ export {
updateComponents,
deleteComponents,
deleteComponent,
resolveComponentUID,
};

View File

@ -25,7 +25,7 @@ const PurchaseReviewWorkflows = () => {
content={formatMessage({
id: 'Settings.review-workflows.not-available',
defaultMessage:
'Review Workflows is only available as part of the Enterprise Edition. Upgrade to create and manage workflows.',
'Review Workflows is only available as part of a paid plan. Upgrade to create and manage workflows.',
})}
action={
<LinkButton

View File

@ -365,7 +365,7 @@ const PLUGIN_TEMPLATE = defineTemplate(async ({ logger, gitConfig, packagePath }
...pkgJson.devDependencies,
'@types/react': '*',
'@types/react-dom': '*',
'@types/styled-components': '5.1.26',
'@types/styled-components': '5.1.32',
};
const { adminTsconfigFiles } = await import('./files/typescript');

View File

@ -44,7 +44,7 @@
},
"dependencies": {
"@casl/ability": "6.5.0",
"@koa/cors": "3.4.3",
"@koa/cors": "5.0.0",
"@koa/router": "10.1.1",
"@strapi/database": "5.0.0-beta.1",
"@strapi/logger": "5.0.0-beta.1",

View File

@ -36,13 +36,13 @@ describe('Upload service', () => {
test('Replaces reserved and unsafe characters for URLs and files in hash', async () => {
const fileData = {
filename: 'File%&Näme<>:"|?*.png',
filename: 'File%&Näme.png',
type: 'image/png',
size: 1000 * 1000,
};
expect(await uploadService.formatFileInfo(fileData)).toMatchObject({
name: 'File%&Näme<>:"|?*.png',
name: 'File%&Näme.png',
hash: expect.stringContaining('File_and_Naeme'),
ext: '.png',
mime: 'image/png',
@ -50,6 +50,18 @@ describe('Upload service', () => {
});
});
test('Prevents invalid characters in file name', async () => {
const fileData = {
filename: 'filename.png\u0000',
type: 'image/png',
size: 1000 * 1000,
};
expect(uploadService.formatFileInfo(fileData)).rejects.toThrowError(
'File name contains invalid characters'
);
});
test('Overrides name with fileInfo', async () => {
const fileData = {
filename: 'File Name.png',

View File

@ -4255,6 +4255,15 @@ __metadata:
languageName: node
linkType: hard
"@koa/cors@npm:5.0.0":
version: 5.0.0
resolution: "@koa/cors@npm:5.0.0"
dependencies:
vary: "npm:^1.1.2"
checksum: 3a0e32fbc422a5f9a41540ce3b7499d46073ddb0e4e851394a74bac5ecd0eaa1f24a8f189b7bd6a50c5863788ae6945c52d990edf99fdd2151a4404f266fe2e7
languageName: node
linkType: hard
"@koa/router@npm:10.1.1":
version: 10.1.1
resolution: "@koa/router@npm:10.1.1"
@ -8330,7 +8339,7 @@ __metadata:
resolution: "@strapi/types@workspace:packages/core/types"
dependencies:
"@casl/ability": "npm:6.5.0"
"@koa/cors": "npm:3.4.3"
"@koa/cors": "npm:5.0.0"
"@koa/router": "npm:10.1.1"
"@strapi/database": "npm:5.0.0-beta.1"
"@strapi/logger": "npm:5.0.0-beta.1"