mirror of
https://github.com/strapi/strapi.git
synced 2025-07-07 09:02:42 +00:00
Merge branch 'develop' into v5/main
This commit is contained in:
commit
660b779c4c
@ -46,7 +46,7 @@ The Strapi core team will review your pull request and either merge it, request
|
|||||||
|
|
||||||
## Contribution Prerequisites
|
## 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).
|
- You are familiar with [Git](https://git-scm.com).
|
||||||
|
|
||||||
**Before submitting your pull request** make sure the following requirements are fulfilled:
|
**Before submitting your pull request** make sure the following requirements are fulfilled:
|
||||||
@ -101,7 +101,7 @@ Start the administration panel server for development:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ./packages/core/admin
|
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.
|
The administration panel should now be available at http://localhost:4000/admin. Make sure the example application (step 4) is still running.
|
||||||
|
@ -40,7 +40,7 @@
|
|||||||
Strapi Community Edition is a free and open-source headless CMS enabling you to manage any content, anywhere.
|
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.
|
- **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.
|
- **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.
|
- **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.
|
- **Blazing Fast and Robust**: Built on top of Node.js and TypeScript, Strapi delivers reliable and solid performance.
|
||||||
|
@ -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 () => {
|
test('List posts with limit', async () => {
|
||||||
const res = await graphqlQuery({
|
const res = await graphqlQuery({
|
||||||
query: /* GraphQL */ `
|
query: /* GraphQL */ `
|
||||||
|
25
api-tests/plugins/graphql/utils.test.api.js
Normal file
25
api-tests/plugins/graphql/utils.test.api.js
Normal 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>');
|
||||||
|
});
|
||||||
|
});
|
@ -12,6 +12,6 @@ tags:
|
|||||||
|
|
||||||
A source provider must implement the interface ISourceProvider found in `packages/core/data-transfer/types/providers.d.ts`.
|
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.
|
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.
|
||||||
|
@ -12,4 +12,4 @@ tags:
|
|||||||
|
|
||||||
A destination provider must implement the interface IDestinationProvider found in `packages/core/data-transfer/types/providers.d.ts`.
|
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.
|
||||||
|
@ -27,20 +27,20 @@ We store order values for all type of relations, except for:
|
|||||||
- Polymorphic relations (too complicated to implement).
|
- Polymorphic relations (too complicated to implement).
|
||||||
- One to one relations (as there is only one relation per pair)
|
- One to one relations (as there is only one relation per pair)
|
||||||
|
|
||||||
### Many to many (Addresses <-> Categories)
|
### Many to many (Addresses <-> Categories)
|
||||||
|
|
||||||
<img src="/img/database/m2m-example.png" alt="many to many relation" />
|
<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.
|
- `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.
|
- `address_order` is the order value of the addresses relations in a category entity.
|
||||||
|
|
||||||
### One to one (Kitchensinks <-> Tags)
|
### One to one (Kitchensinks <-> Tags)
|
||||||
|
|
||||||
<img src="/img/database/o2o-example.png" alt="one to one relation" />
|
<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.
|
- there is no `order` fields as there is only one relation per pair.
|
||||||
|
|
||||||
### One way relation (Restaurants <-> Categories)
|
### One way relation (Restaurants <-> Categories)
|
||||||
|
|
||||||
Where a restaurant has many 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**
|
- If an **id** was **already in this array, remove the previous one**
|
||||||
- **Grouping by the order value**, and ignoring init relations
|
- **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.
|
- 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**
|
- **Insert values in the database**
|
||||||
- **Update database order based on their order position.** (using ROW_NUMBER() clause)
|
- **Update database order based on their order position.** (using ROW_NUMBER() clause)
|
||||||
|
|
@ -1,8 +1,9 @@
|
|||||||
// @ts-check
|
// @ts-check
|
||||||
// Note: type annotations allow type checking and IDEs autocompletion
|
// Note: type annotations allow type checking and IDEs autocompletion
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const lightCodeTheme = require('prism-react-renderer/themes/github');
|
const {
|
||||||
const darkCodeTheme = require('prism-react-renderer/themes/dracula');
|
themes: { github: lightCodeTheme, dracula: darkCodeTheme },
|
||||||
|
} = require('prism-react-renderer');
|
||||||
|
|
||||||
/** @type {import('@docusaurus/types').Config} */
|
/** @type {import('@docusaurus/types').Config} */
|
||||||
const config = {
|
const config = {
|
||||||
|
@ -26,16 +26,16 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docusaurus/core": "2.2.0",
|
"@docusaurus/core": "3.1.1",
|
||||||
"@docusaurus/preset-classic": "2.2.0",
|
"@docusaurus/preset-classic": "3.1.1",
|
||||||
"@mdx-js/react": "^1.6.22",
|
"@mdx-js/react": "^3.0.0",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
"prism-react-renderer": "^1.3.3",
|
"prism-react-renderer": "^2.1.0",
|
||||||
"react": "^17.0.2",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^17.0.2"
|
"react-dom": "^18.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@docusaurus/module-type-aliases": "2.2.0",
|
"@docusaurus/module-type-aliases": "3.1.1",
|
||||||
"docusaurus-plugin-typedoc": "0.22.0",
|
"docusaurus-plugin-typedoc": "0.22.0",
|
||||||
"typedoc": "0.25.9",
|
"typedoc": "0.25.9",
|
||||||
"typedoc-plugin-markdown": "3.17.1",
|
"typedoc-plugin-markdown": "3.17.1",
|
||||||
|
8156
docs/yarn.lock
8156
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@ -98,7 +98,6 @@ test.describe('Sign Up', () => {
|
|||||||
}) => {
|
}) => {
|
||||||
await page.getByRole('button', { name: "Let's start" }).click();
|
await page.getByRole('button', { name: "Let's start" }).click();
|
||||||
|
|
||||||
await page.waitForURL('**/admin');
|
|
||||||
await expect(page).toHaveTitle('Homepage | Strapi');
|
await expect(page).toHaveTitle('Homepage | Strapi');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -91,4 +91,35 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
|
|||||||
await page.getByRole('link', { name: 'Releases' }).click();
|
await page.getByRole('link', { name: 'Releases' }).click();
|
||||||
await expect(page.getByRole('link', { name: `${newReleaseName}` })).toBeVisible();
|
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,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -155,7 +155,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
|
|||||||
to: '/settings/transfer-tokens?sort=name:ASC',
|
to: '/settings/transfer-tokens?sort=name:ASC',
|
||||||
id: 'transfer-tokens',
|
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
|
// Disable this by adding "promoteEE: false" to your `./config/admin.js` file
|
||||||
...(!window.strapi.features.isEnabled(window.strapi.features.SSO) &&
|
...(!window.strapi.features.isEnabled(window.strapi.features.SSO) &&
|
||||||
window.strapi?.flags?.promoteEE
|
window.strapi?.flags?.promoteEE
|
||||||
@ -163,7 +163,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
|
|||||||
{
|
{
|
||||||
intlLabel: { id: 'Settings.sso.title', defaultMessage: 'Single Sign-On' },
|
intlLabel: { id: 'Settings.sso.title', defaultMessage: 'Single Sign-On' },
|
||||||
to: '/settings/purchase-single-sign-on',
|
to: '/settings/purchase-single-sign-on',
|
||||||
id: 'sso',
|
id: 'sso-purchase-page',
|
||||||
lockIcon: true,
|
lockIcon: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -178,7 +178,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
|
|||||||
defaultMessage: 'Review Workflows',
|
defaultMessage: 'Review Workflows',
|
||||||
},
|
},
|
||||||
to: '/settings/purchase-review-workflows',
|
to: '/settings/purchase-review-workflows',
|
||||||
id: 'review-workflows',
|
id: 'review-workflows-purchase-page',
|
||||||
lockIcon: true,
|
lockIcon: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@ -203,7 +203,7 @@ export const SETTINGS_LINKS_CE = (): SettingsMenu => ({
|
|||||||
{
|
{
|
||||||
intlLabel: { id: 'global.auditLogs', defaultMessage: 'Audit Logs' },
|
intlLabel: { id: 'global.auditLogs', defaultMessage: 'Audit Logs' },
|
||||||
to: '/settings/purchase-audit-logs',
|
to: '/settings/purchase-audit-logs',
|
||||||
id: 'auditLogs',
|
id: 'auditLogs-purchase-page',
|
||||||
lockIcon: true,
|
lockIcon: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
@ -22,7 +22,7 @@ const PurchaseAuditLogs = () => {
|
|||||||
content={formatMessage({
|
content={formatMessage({
|
||||||
id: 'Settings.permissions.auditLogs.not-available',
|
id: 'Settings.permissions.auditLogs.not-available',
|
||||||
defaultMessage:
|
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={
|
action={
|
||||||
<LinkButton
|
<LinkButton
|
||||||
|
@ -25,7 +25,7 @@ const PurchaseSingleSignOn = () => {
|
|||||||
content={formatMessage({
|
content={formatMessage({
|
||||||
id: 'Settings.sso.not-available',
|
id: 'Settings.sso.not-available',
|
||||||
defaultMessage:
|
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={
|
action={
|
||||||
<LinkButton
|
<LinkButton
|
||||||
|
@ -162,7 +162,7 @@
|
|||||||
"Settings.permissions.auditLogs.entry.update": "Update entry{model, select, undefined {} other { ({model})}}",
|
"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.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.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.create": "Create media",
|
||||||
"Settings.permissions.auditLogs.media.delete": "Delete media",
|
"Settings.permissions.auditLogs.media.delete": "Delete media",
|
||||||
"Settings.permissions.auditLogs.media.update": "Update 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.description": "Create new user on SSO login if no account exists",
|
||||||
"Settings.sso.form.registration.label": "Auto-registration",
|
"Settings.sso.form.registration.label": "Auto-registration",
|
||||||
"Settings.sso.title": "Single Sign-On",
|
"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.cancel": "Cancel",
|
||||||
"Settings.tokens.Button.regenerate": "Regenerate",
|
"Settings.tokens.Button.regenerate": "Regenerate",
|
||||||
"Settings.tokens.ListView.headers.createdAt": "Created at",
|
"Settings.tokens.ListView.headers.createdAt": "Created at",
|
||||||
|
@ -14,7 +14,6 @@ import {
|
|||||||
DialogFooter,
|
DialogFooter,
|
||||||
DialogProps,
|
DialogProps,
|
||||||
Flex,
|
Flex,
|
||||||
ModalBody,
|
|
||||||
ModalHeader,
|
ModalHeader,
|
||||||
ModalLayout,
|
ModalLayout,
|
||||||
Typography,
|
Typography,
|
||||||
@ -69,8 +68,7 @@ interface NotificationOptions {
|
|||||||
interface ModalOptions {
|
interface ModalOptions {
|
||||||
type: 'modal';
|
type: 'modal';
|
||||||
title: string;
|
title: string;
|
||||||
content: React.ReactNode;
|
content: React.ComponentType<{ onClose: () => void }>;
|
||||||
footer: React.ComponentType<{ onClose: () => void }> | React.ReactNode;
|
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -261,8 +259,7 @@ const BulkActionModal = ({
|
|||||||
isOpen,
|
isOpen,
|
||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
footer: Footer,
|
content: Content,
|
||||||
content,
|
|
||||||
onModalClose,
|
onModalClose,
|
||||||
}: BulkActionModalProps) => {
|
}: BulkActionModalProps) => {
|
||||||
const id = React.useId();
|
const id = React.useId();
|
||||||
@ -286,8 +283,7 @@ const BulkActionModal = ({
|
|||||||
{title}
|
{title}
|
||||||
</Typography>
|
</Typography>
|
||||||
</ModalHeader>
|
</ModalHeader>
|
||||||
<ModalBody>{content}</ModalBody>
|
<Content onClose={handleClose} />
|
||||||
<>{typeof Footer === 'function' ? <Footer onClose={handleClose} /> : Footer}</>
|
|
||||||
</ModalLayout>
|
</ModalLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -11,6 +11,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Typography,
|
Typography,
|
||||||
|
ModalBody,
|
||||||
ModalFooter,
|
ModalFooter,
|
||||||
IconButton,
|
IconButton,
|
||||||
Flex,
|
Flex,
|
||||||
@ -35,7 +36,7 @@ import { getTranslation } from '../../../../utils/translations';
|
|||||||
import { getInnerErrors, createYupSchema } from '../../../../utils/validation';
|
import { getInnerErrors, createYupSchema } from '../../../../utils/validation';
|
||||||
// import { useAllowedActions } from '../../hooks/useAllowedActions';
|
// import { useAllowedActions } from '../../hooks/useAllowedActions';
|
||||||
|
|
||||||
import { ConfirmDialogPublishAll } from './ConfirmBulkActionDialog';
|
import { ConfirmDialogPublishAll, ConfirmDialogPublishAllProps } from './ConfirmBulkActionDialog';
|
||||||
|
|
||||||
import type { BulkActionComponent } from '../../../../content-manager';
|
import type { BulkActionComponent } from '../../../../content-manager';
|
||||||
import type { Data } from '@strapi/types';
|
import type { Data } from '@strapi/types';
|
||||||
@ -248,9 +249,6 @@ const SelectedEntriesModalContent = ({
|
|||||||
// setEntriesToFetch,
|
// setEntriesToFetch,
|
||||||
// setSelectedListViewEntries,
|
// setSelectedListViewEntries,
|
||||||
validationErrors = {},
|
validationErrors = {},
|
||||||
isDialogOpen,
|
|
||||||
setIsDialogOpen,
|
|
||||||
setIsPublishModalBtnDisabled,
|
|
||||||
}: SelectedEntriesModalContentProps) => {
|
}: SelectedEntriesModalContentProps) => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
const {
|
const {
|
||||||
@ -260,7 +258,6 @@ const SelectedEntriesModalContent = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
} = useTable('SelectedEntriesModal', (state) => state);
|
} = useTable('SelectedEntriesModal', (state) => state);
|
||||||
const [rowsToDisplay, setRowsToDisplay] = React.useState<Array<TableRow>>([]);
|
const [rowsToDisplay, setRowsToDisplay] = React.useState<Array<TableRow>>([]);
|
||||||
|
|
||||||
const [publishedCount, setPublishedCount] = React.useState(0);
|
const [publishedCount, setPublishedCount] = React.useState(0);
|
||||||
const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler();
|
const { _unstableFormatAPIError: formatAPIError } = useAPIErrorHandler();
|
||||||
|
|
||||||
@ -280,11 +277,11 @@ const SelectedEntriesModalContent = ({
|
|||||||
const selectedEntriesWithNoErrorsCount =
|
const selectedEntriesWithNoErrorsCount =
|
||||||
selectedEntries.length - selectedEntriesWithErrorsCount - selectedEntriesPublished;
|
selectedEntries.length - selectedEntriesWithErrorsCount - selectedEntriesPublished;
|
||||||
|
|
||||||
const toggleDialog = () => setIsDialogOpen((prev) => !prev);
|
// const toggleDialog = () => setIsDialogOpen((prev) => !prev);
|
||||||
|
|
||||||
const [publishManyDocuments, { isLoading: isSubmittingForm }] = usePublishManyDocumentsMutation();
|
const [publishManyDocuments, { isLoading: isSubmittingForm }] = usePublishManyDocumentsMutation();
|
||||||
const handleConfirmBulkPublish = async () => {
|
const handleConfirmBulkPublish = async () => {
|
||||||
toggleDialog();
|
// toggleDialog();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// @ts-expect-error – TODO: this still expects Entity.ID instead of Document.ID
|
// @ts-expect-error – TODO: this still expects Entity.ID instead of Document.ID
|
||||||
@ -377,19 +374,7 @@ const SelectedEntriesModalContent = ({
|
|||||||
// Update the rows to display
|
// Update the rows to display
|
||||||
setRowsToDisplay(rows);
|
setRowsToDisplay(rows);
|
||||||
}
|
}
|
||||||
}, [rows, setRowsToDisplay]);
|
}, [rows]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (
|
|
||||||
selectedEntries.length === 0 ||
|
|
||||||
selectedEntries.length === selectedEntriesWithErrorsCount ||
|
|
||||||
isLoading
|
|
||||||
) {
|
|
||||||
setIsPublishModalBtnDisabled(true);
|
|
||||||
} else {
|
|
||||||
setIsPublishModalBtnDisabled(false);
|
|
||||||
}
|
|
||||||
}, [isLoading, selectedEntries, selectedEntriesWithErrorsCount, setIsPublishModalBtnDisabled]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -402,12 +387,12 @@ const SelectedEntriesModalContent = ({
|
|||||||
validationErrors={validationErrors}
|
validationErrors={validationErrors}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<ConfirmDialogPublishAll
|
{/* <ConfirmDialogPublishAll
|
||||||
isOpen={isDialogOpen}
|
// isOpen={isDialogOpen}
|
||||||
onToggleDialog={toggleDialog}
|
// onToggleDialog={toggleDialog}
|
||||||
isConfirmButtonLoading={isSubmittingForm}
|
isConfirmButtonLoading={isSubmittingForm}
|
||||||
onConfirm={handleConfirmBulkPublish}
|
onConfirm={handleConfirmBulkPublish}
|
||||||
/>
|
/> */}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -416,158 +401,163 @@ const SelectedEntriesModalContent = ({
|
|||||||
* PublishAction
|
* PublishAction
|
||||||
* -----------------------------------------------------------------------------------------------*/
|
* -----------------------------------------------------------------------------------------------*/
|
||||||
|
|
||||||
const PublishAction: BulkActionComponent = ({ model: slug }) => {
|
const PublishAction: BulkActionComponent = () => {
|
||||||
const { formatMessage } = useIntl();
|
/**
|
||||||
|
* TODO: fix this in V5 with the rest of bulk actions
|
||||||
|
*/
|
||||||
|
return null;
|
||||||
|
|
||||||
const { selectedRows: selectedListViewEntries, selectRow: setSelectedListViewEntries } = useTable(
|
// const { formatMessage } = useIntl();
|
||||||
'SelectedEntriesModal',
|
|
||||||
(state) => state
|
|
||||||
);
|
|
||||||
|
|
||||||
const { model, schema, components, isLoading: isLoadingDoc } = useDoc();
|
// const { selectedRows: selectedListViewEntries, selectRow: setSelectedListViewEntries } = useTable(
|
||||||
// const selectedEntriesObjects = list.filter((entry) => selectedEntries.includes(entry.id));
|
// 'SelectedEntriesModal',
|
||||||
// const hasPublishPermission = useAllowedActions(slug).canPublish;
|
// (state) => state
|
||||||
const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
// );
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// const showPublishButton =
|
|
||||||
// hasPublishPermission && selectedEntriesObjects.some((entry) => !entry.publishedAt);
|
|
||||||
const [isPublishModalBtnDisabled, setIsPublishModalBtnDisabled] = React.useState(true);
|
|
||||||
|
|
||||||
// The child table will update this value based on the entries that were published
|
// const { model, schema, components, isLoading: isLoadingDoc } = useDoc();
|
||||||
const [entriesToFetch, setEntriesToFetch] = React.useState(selectedListViewEntries);
|
// // const selectedEntriesObjects = list.filter((entry) => selectedEntries.includes(entry.id));
|
||||||
// We want to keep the selected entries order same as the list view
|
// // const hasPublishPermission = useAllowedActions(slug).canPublish;
|
||||||
const [
|
// const [isDialogOpen, setIsDialogOpen] = React.useState(false);
|
||||||
{
|
// const queryClient = useQueryClient();
|
||||||
query: { sort, plugins },
|
// // const showPublishButton =
|
||||||
},
|
// // hasPublishPermission && selectedEntriesObjects.some((entry) => !entry.publishedAt);
|
||||||
] = useQueryParams<{ sort?: string; plugins?: Record<string, any> }>();
|
// const [isPublishModalBtnDisabled, setIsPublishModalBtnDisabled] = React.useState(true);
|
||||||
|
|
||||||
const { data, isLoading, isFetching } = useGetAllDocumentsQuery(
|
// // The child table will update this value based on the entries that were published
|
||||||
{
|
// const [entriesToFetch, setEntriesToFetch] = React.useState(selectedListViewEntries);
|
||||||
model,
|
// // We want to keep the selected entries order same as the list view
|
||||||
params: {
|
// const [
|
||||||
page: '1',
|
// {
|
||||||
pageSize: entriesToFetch.length.toString(),
|
// query: { sort, plugins },
|
||||||
sort,
|
// },
|
||||||
filters: {
|
// ] = useQueryParams<{ sort?: string; plugins?: Record<string, any> }>();
|
||||||
id: {
|
|
||||||
$in: entriesToFetch,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
locale: plugins?.i18n?.locale,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
selectFromResult: ({ data, ...restRes }) => ({ data: data?.results ?? [], ...restRes }),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { rows, validationErrors } = React.useMemo(() => {
|
// const { data, isLoading, isFetching } = useGetAllDocumentsQuery(
|
||||||
if (data.length > 0 && schema) {
|
// {
|
||||||
const validate = createYupSchema(schema.attributes, components);
|
// model,
|
||||||
const validationErrors: Record<Data.ID, Record<string, MessageDescriptor>> = {};
|
// params: {
|
||||||
const rows = data.map((entry) => {
|
// page: '1',
|
||||||
try {
|
// pageSize: entriesToFetch.length.toString(),
|
||||||
validate.validateSync(entry, { abortEarly: false });
|
// sort,
|
||||||
|
// filters: {
|
||||||
|
// id: {
|
||||||
|
// $in: entriesToFetch,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// locale: plugins?.i18n?.locale,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// selectFromResult: ({ data, ...restRes }) => ({ data: data?.results ?? [], ...restRes }),
|
||||||
|
// }
|
||||||
|
// );
|
||||||
|
|
||||||
return entry;
|
// const { rows, validationErrors } = React.useMemo(() => {
|
||||||
} catch (e) {
|
// if (data.length > 0 && schema) {
|
||||||
if (e instanceof ValidationError) {
|
// const validate = createYupSchema(schema.attributes, components);
|
||||||
validationErrors[entry.id] = getInnerErrors(e);
|
// 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 {
|
// return { rows, validationErrors };
|
||||||
rows: [],
|
// }
|
||||||
validationErrors: {},
|
|
||||||
};
|
|
||||||
}, [components, data, schema]);
|
|
||||||
|
|
||||||
const refetchList = () => {
|
// return {
|
||||||
queryClient.invalidateQueries(['content-manager', 'collection-types', slug]);
|
// rows: [],
|
||||||
};
|
// validationErrors: {},
|
||||||
|
// };
|
||||||
|
// }, [components, data, schema]);
|
||||||
|
|
||||||
// If all the entries are published, we want to refetch the list view
|
// const refetchList = () => {
|
||||||
if (rows.length === 0) {
|
// // queryClient.invalidateQueries(['content-manager', 'collection-types', slug]);
|
||||||
refetchList();
|
// };
|
||||||
}
|
|
||||||
|
|
||||||
// if (!showPublishButton) return null;
|
// // If all the entries are published, we want to refetch the list view
|
||||||
|
// if (rows.length === 0) {
|
||||||
|
// refetchList();
|
||||||
|
// }
|
||||||
|
|
||||||
return {
|
// // if (!showPublishButton) return null;
|
||||||
actionType: 'publish',
|
|
||||||
variant: 'tertiary',
|
// return {
|
||||||
label: formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' }),
|
// actionType: 'publish',
|
||||||
dialog: {
|
// variant: 'tertiary',
|
||||||
type: 'modal',
|
// label: formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' }),
|
||||||
title: formatMessage({
|
// dialog: {
|
||||||
id: getTranslation('containers.ListPage.selectedEntriesModal.title'),
|
// type: 'modal',
|
||||||
defaultMessage: 'Publish entries',
|
// title: formatMessage({
|
||||||
}),
|
// id: getTranslation('containers.ListPage.selectedEntriesModal.title'),
|
||||||
content: (
|
// defaultMessage: 'Publish entries',
|
||||||
<Table.Root
|
// }),
|
||||||
rows={rows}
|
// content: (
|
||||||
defaultSelectedRows={selectedListViewEntries}
|
// <Table.Root
|
||||||
headers={TABLE_HEADERS}
|
// rows={rows}
|
||||||
isLoading={isLoading || isLoadingDoc || isFetching}
|
// defaultSelectedRows={selectedListViewEntries}
|
||||||
>
|
// headers={TABLE_HEADERS}
|
||||||
<SelectedEntriesModalContent
|
// isLoading={isLoading || isLoadingDoc || isFetching}
|
||||||
isDialogOpen={isDialogOpen}
|
// >
|
||||||
setIsDialogOpen={setIsDialogOpen}
|
// <SelectedEntriesModalContent
|
||||||
refetchList={refetchList}
|
// isDialogOpen={isDialogOpen}
|
||||||
setSelectedListViewEntries={setSelectedListViewEntries}
|
// setIsDialogOpen={setIsDialogOpen}
|
||||||
setEntriesToFetch={setEntriesToFetch}
|
// refetchList={refetchList}
|
||||||
validationErrors={validationErrors}
|
// setSelectedListViewEntries={setSelectedListViewEntries}
|
||||||
setIsPublishModalBtnDisabled={setIsPublishModalBtnDisabled}
|
// setEntriesToFetch={setEntriesToFetch}
|
||||||
/>
|
// validationErrors={validationErrors}
|
||||||
</Table.Root>
|
// setIsPublishModalBtnDisabled={setIsPublishModalBtnDisabled}
|
||||||
),
|
// />
|
||||||
footer: ({ onClose }) => {
|
// </Table.Root>
|
||||||
return (
|
// ),
|
||||||
<ModalFooter
|
// footer: ({ onClose }) => {
|
||||||
startActions={
|
// return (
|
||||||
<Button
|
// <ModalFooter
|
||||||
onClick={() => {
|
// startActions={
|
||||||
onClose();
|
// <Button
|
||||||
refetchList();
|
// onClick={() => {
|
||||||
}}
|
// onClose();
|
||||||
variant="tertiary"
|
// refetchList();
|
||||||
>
|
// }}
|
||||||
{formatMessage({
|
// variant="tertiary"
|
||||||
id: 'app.components.Button.cancel',
|
// >
|
||||||
defaultMessage: 'Cancel',
|
// {formatMessage({
|
||||||
})}
|
// id: 'app.components.Button.cancel',
|
||||||
</Button>
|
// defaultMessage: 'Cancel',
|
||||||
}
|
// })}
|
||||||
endActions={
|
// </Button>
|
||||||
<Flex gap={2}>
|
// }
|
||||||
<Button /* onClick={() => refetchModalData()} */ variant="tertiary">
|
// endActions={
|
||||||
{formatMessage({ id: 'app.utils.refresh', defaultMessage: 'Refresh' })}
|
// <Flex gap={2}>
|
||||||
</Button>
|
// <Button /* onClick={() => refetchModalData()} */ variant="tertiary">
|
||||||
<Button
|
// {formatMessage({ id: 'app.utils.refresh', defaultMessage: 'Refresh' })}
|
||||||
onClick={() => setIsDialogOpen((prev) => !prev)}
|
// </Button>
|
||||||
disabled={isPublishModalBtnDisabled}
|
// <Button
|
||||||
// TODO: in V5 when bulk actions are refactored, we should use the isLoading prop
|
// onClick={() => setIsDialogOpen((prev) => !prev)}
|
||||||
// loading={bulkPublishMutation.isLoading}
|
// disabled={isPublishModalBtnDisabled}
|
||||||
>
|
// // TODO: in V5 when bulk actions are refactored, we should use the isLoading prop
|
||||||
{formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' })}
|
// // loading={bulkPublishMutation.isLoading}
|
||||||
</Button>
|
// >
|
||||||
</Flex>
|
// {formatMessage({ id: 'app.utils.publish', defaultMessage: 'Publish' })}
|
||||||
}
|
// </Button>
|
||||||
/>
|
// </Flex>
|
||||||
);
|
// }
|
||||||
},
|
// />
|
||||||
onClose: () => {
|
// );
|
||||||
refetchList();
|
// },
|
||||||
},
|
// onClose: () => {
|
||||||
},
|
// refetchList();
|
||||||
};
|
// },
|
||||||
|
// },
|
||||||
|
// };
|
||||||
};
|
};
|
||||||
|
|
||||||
export { PublishAction, SelectedEntriesModalContent };
|
export { PublishAction, SelectedEntriesModalContent };
|
||||||
|
@ -45,17 +45,17 @@ import type { UID } from '@strapi/types';
|
|||||||
* AddActionToReleaseModal
|
* 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(),
|
type: yup.string().oneOf(['publish', 'unpublish']).required(),
|
||||||
releaseId: yup.string().required(),
|
releaseId: yup.string().required(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interface FormValues {
|
export interface FormValues {
|
||||||
type: CreateReleaseAction.Request['body']['type'];
|
type: CreateReleaseAction.Request['body']['type'];
|
||||||
releaseId: CreateReleaseAction.Request['params']['releaseId'];
|
releaseId: CreateReleaseAction.Request['params']['releaseId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_VALUES = {
|
export const INITIAL_VALUES = {
|
||||||
type: 'publish',
|
type: 'publish',
|
||||||
releaseId: '',
|
releaseId: '',
|
||||||
} satisfies FormValues;
|
} satisfies FormValues;
|
||||||
@ -66,7 +66,7 @@ interface AddActionToReleaseModalProps {
|
|||||||
entryId: GetContentTypeEntryReleases.Request['query']['entryId'];
|
entryId: GetContentTypeEntryReleases.Request['query']['entryId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NoReleases = () => {
|
export const NoReleases = () => {
|
||||||
const { formatMessage } = useIntl();
|
const { formatMessage } = useIntl();
|
||||||
return (
|
return (
|
||||||
<EmptyStateLayout
|
<EmptyStateLayout
|
||||||
|
@ -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 };
|
@ -28,7 +28,7 @@ import { getTimezoneOffset } from '../utils/time';
|
|||||||
|
|
||||||
export interface FormValues {
|
export interface FormValues {
|
||||||
name: string;
|
name: string;
|
||||||
date: Date | null;
|
date: string | null;
|
||||||
time: string;
|
time: string;
|
||||||
timezone: string | null;
|
timezone: string | null;
|
||||||
isScheduled?: boolean;
|
isScheduled?: boolean;
|
||||||
@ -62,9 +62,8 @@ export const ReleaseModal = ({
|
|||||||
const getScheduledTimestamp = (values: FormValues) => {
|
const getScheduledTimestamp = (values: FormValues) => {
|
||||||
const { date, time, timezone } = values;
|
const { date, time, timezone } = values;
|
||||||
if (!date || !time || !timezone) return null;
|
if (!date || !time || !timezone) return null;
|
||||||
const formattedDate = parse(time, 'HH:mm', new Date(date));
|
|
||||||
const timezoneWithoutOffset = timezone.split('&')[1];
|
const timezoneWithoutOffset = timezone.split('&')[1];
|
||||||
return zonedTimeToUtc(formattedDate, timezoneWithoutOffset);
|
return zonedTimeToUtc(`${date} ${time}`, timezoneWithoutOffset);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -1,12 +1,14 @@
|
|||||||
import { PaperPlane } from '@strapi/icons';
|
import { PaperPlane } from '@strapi/icons';
|
||||||
|
|
||||||
import { CMReleasesContainer } from './components/CMReleasesContainer';
|
import { CMReleasesContainer } from './components/CMReleasesContainer';
|
||||||
|
import { ReleaseAction } from './components/ReleaseAction';
|
||||||
import { PERMISSIONS } from './constants';
|
import { PERMISSIONS } from './constants';
|
||||||
import { pluginId } from './pluginId';
|
import { pluginId } from './pluginId';
|
||||||
import { releaseApi } from './services/release';
|
import { releaseApi } from './services/release';
|
||||||
import { prefixPluginTranslations } from './utils/prefixPluginTranslations';
|
import { prefixPluginTranslations } from './utils/prefixPluginTranslations';
|
||||||
|
|
||||||
import type { StrapiApp } from '@strapi/admin/strapi-admin';
|
import type { StrapiApp } from '@strapi/admin/strapi-admin';
|
||||||
|
import type { BulkActionComponent } from '@strapi/plugin-content-manager/strapi-admin';
|
||||||
import type { Plugin } from '@strapi/types';
|
import type { Plugin } from '@strapi/types';
|
||||||
|
|
||||||
// eslint-disable-next-line import/no-default-export
|
// eslint-disable-next-line import/no-default-export
|
||||||
@ -41,6 +43,15 @@ const admin: Plugin.Config.AdminInput = {
|
|||||||
name: `${pluginId}-link`,
|
name: `${pluginId}-link`,
|
||||||
Component: CMReleasesContainer,
|
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 (
|
} else if (
|
||||||
!window.strapi.features.isEnabled('cms-content-releases') &&
|
!window.strapi.features.isEnabled('cms-content-releases') &&
|
||||||
window.strapi?.flags?.promoteEE
|
window.strapi?.flags?.promoteEE
|
||||||
|
@ -25,7 +25,7 @@ const PurchaseContentReleases = () => {
|
|||||||
content={formatMessage({
|
content={formatMessage({
|
||||||
id: 'content-releases.pages.PurchaseRelease.not-available',
|
id: 'content-releases.pages.PurchaseRelease.not-available',
|
||||||
defaultMessage:
|
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={
|
action={
|
||||||
<LinkButton
|
<LinkButton
|
||||||
|
@ -873,7 +873,7 @@ const ReleaseDetailsPage = () => {
|
|||||||
const scheduledAt =
|
const scheduledAt =
|
||||||
releaseData?.scheduledAt && timezone ? utcToZonedTime(releaseData.scheduledAt, timezone) : null;
|
releaseData?.scheduledAt && timezone ? utcToZonedTime(releaseData.scheduledAt, timezone) : null;
|
||||||
// Just get the date and time to display without considering updated timezone time
|
// 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 time = scheduledAt ? format(scheduledAt, 'HH:mm') : '';
|
||||||
|
|
||||||
const handleEditRelease = async (values: FormValues) => {
|
const handleEditRelease = async (values: FormValues) => {
|
||||||
|
@ -37,7 +37,7 @@ import { useNavigate, useLocation } from 'react-router-dom';
|
|||||||
import styled from 'styled-components';
|
import styled from 'styled-components';
|
||||||
|
|
||||||
import { GetReleases, type Release } from '../../../shared/contracts/releases';
|
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 { ReleaseModal, FormValues } from '../components/ReleaseModal';
|
||||||
import { PERMISSIONS } from '../constants';
|
import { PERMISSIONS } from '../constants';
|
||||||
import { isAxiosError } from '../services/axios';
|
import { isAxiosError } from '../services/axios';
|
||||||
@ -60,8 +60,11 @@ const LinkCard = styled(Link)`
|
|||||||
display: block;
|
display: block;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CapitalizeRelativeTime = styled(RelativeTime)`
|
const RelativeTime = styled(BaseRelativeTime)`
|
||||||
text-transform: capitalize;
|
display: inline-block;
|
||||||
|
&::first-letter {
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const getBadgeProps = (status: Release['status']) => {
|
const getBadgeProps = (status: Release['status']) => {
|
||||||
@ -138,7 +141,7 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
|
|||||||
</Typography>
|
</Typography>
|
||||||
<Typography variant="pi" textColor="neutral600">
|
<Typography variant="pi" textColor="neutral600">
|
||||||
{scheduledAt ? (
|
{scheduledAt ? (
|
||||||
<CapitalizeRelativeTime timestamp={new Date(scheduledAt)} />
|
<RelativeTime timestamp={new Date(scheduledAt)} />
|
||||||
) : (
|
) : (
|
||||||
formatMessage({
|
formatMessage({
|
||||||
id: 'content-releases.pages.Releases.not-scheduled',
|
id: 'content-releases.pages.Releases.not-scheduled',
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
import { within } from '@testing-library/react';
|
import { within } from '@testing-library/react';
|
||||||
import { render, server, screen } from '@tests/utils';
|
import { render, server, screen } from '@tests/utils';
|
||||||
import { rest } from 'msw';
|
import { rest } from 'msw';
|
||||||
|
@ -2,6 +2,7 @@ import { createApi } from '@reduxjs/toolkit/query/react';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreateReleaseAction,
|
CreateReleaseAction,
|
||||||
|
CreateManyReleaseActions,
|
||||||
DeleteReleaseAction,
|
DeleteReleaseAction,
|
||||||
} from '../../../shared/contracts/release-actions';
|
} from '../../../shared/contracts/release-actions';
|
||||||
import { pluginId } from '../pluginId';
|
import { pluginId } from '../pluginId';
|
||||||
@ -185,6 +186,22 @@ const releaseApi = createApi({
|
|||||||
{ type: 'ReleaseAction', id: 'LIST' },
|
{ 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: build.mutation<
|
||||||
UpdateReleaseAction.Response,
|
UpdateReleaseAction.Response,
|
||||||
UpdateReleaseAction.Request & { query: GetReleaseActions.Request['query'] } & {
|
UpdateReleaseAction.Request & { query: GetReleaseActions.Request['query'] } & {
|
||||||
@ -269,6 +286,7 @@ const {
|
|||||||
useGetReleaseActionsQuery,
|
useGetReleaseActionsQuery,
|
||||||
useCreateReleaseMutation,
|
useCreateReleaseMutation,
|
||||||
useCreateReleaseActionMutation,
|
useCreateReleaseActionMutation,
|
||||||
|
useCreateManyReleaseActionsMutation,
|
||||||
useUpdateReleaseMutation,
|
useUpdateReleaseMutation,
|
||||||
useUpdateReleaseActionMutation,
|
useUpdateReleaseActionMutation,
|
||||||
usePublishReleaseMutation,
|
usePublishReleaseMutation,
|
||||||
@ -283,6 +301,7 @@ export {
|
|||||||
useGetReleaseActionsQuery,
|
useGetReleaseActionsQuery,
|
||||||
useCreateReleaseMutation,
|
useCreateReleaseMutation,
|
||||||
useCreateReleaseActionMutation,
|
useCreateReleaseActionMutation,
|
||||||
|
useCreateManyReleaseActionsMutation,
|
||||||
useUpdateReleaseMutation,
|
useUpdateReleaseMutation,
|
||||||
useUpdateReleaseActionMutation,
|
useUpdateReleaseActionMutation,
|
||||||
usePublishReleaseMutation,
|
usePublishReleaseMutation,
|
||||||
|
@ -15,6 +15,14 @@
|
|||||||
"content-releases.content-manager-edit-view.edit-entry": "Edit entry",
|
"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.remove-from-release.notification.success": "Entry removed from release",
|
||||||
"content-manager-edit-view.release-action-menu": "Release action options",
|
"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",
|
"content-manager.notification.entry-error": "Failed to get entry data",
|
||||||
"plugin.name": "Releases",
|
"plugin.name": "Releases",
|
||||||
"pages.Releases.title": "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.message": "Upgrade to manage an unlimited number of releases.",
|
||||||
"pages.Releases.max-limit-reached.action": "Explore plans",
|
"pages.Releases.max-limit-reached.action": "Explore plans",
|
||||||
"pages.PurchaseRelease.subTitle": "Manage content updates and releases.",
|
"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.add-release": "New Release",
|
||||||
"header.actions.refresh": "Refresh",
|
"header.actions.refresh": "Refresh",
|
||||||
"header.actions.publish": "Publish",
|
"header.actions.publish": "Publish",
|
||||||
|
@ -1,12 +1,13 @@
|
|||||||
import { Writable } from 'stream';
|
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 { ProviderTransferError } from '../../../../../errors/providers';
|
||||||
import type { IEntity, Transaction } from '../../../../../../types';
|
import type { IEntity, Transaction } from '../../../../../../types';
|
||||||
import { json } from '../../../../../utils';
|
import { json } from '../../../../../utils';
|
||||||
import * as queries from '../../../../queries';
|
import * as queries from '../../../../queries';
|
||||||
|
import { resolveComponentUID } from '../../../../../utils/components';
|
||||||
|
|
||||||
interface IEntitiesRestoreStreamOptions {
|
interface IEntitiesRestoreStreamOptions {
|
||||||
strapi: Core.LoadedStrapi;
|
strapi: Core.LoadedStrapi;
|
||||||
@ -18,7 +19,7 @@ interface IEntitiesRestoreStreamOptions {
|
|||||||
transaction?: Transaction;
|
transaction?: Transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
|
export const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
|
||||||
const { strapi, updateMappingTable, transaction } = options;
|
const { strapi, updateMappingTable, transaction } = options;
|
||||||
const query = queries.entity.createEntityQuery(strapi);
|
const query = queries.entity.createEntityQuery(strapi);
|
||||||
|
|
||||||
@ -31,48 +32,6 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
|
|||||||
const { create, getDeepPopulateComponentLikeQuery } = query(type);
|
const { create, getDeepPopulateComponentLikeQuery } = query(type);
|
||||||
const contentType = strapi.getModel(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 {
|
try {
|
||||||
const created = await create({
|
const created = await create({
|
||||||
data,
|
data,
|
||||||
@ -88,8 +47,8 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
|
|||||||
// For each difference found on an ID attribute,
|
// For each difference found on an ID attribute,
|
||||||
// update the mapping the table accordingly
|
// update the mapping the table accordingly
|
||||||
diffs.forEach((diff) => {
|
diffs.forEach((diff) => {
|
||||||
if (diff.kind === 'modified' && last(diff.path) === 'id') {
|
if (diff.kind === 'modified' && last(diff.path) === 'id' && 'kind' in contentType) {
|
||||||
const target = resolveType(diff.path);
|
const target = resolveComponentUID({ paths: diff.path, data, contentType, strapi });
|
||||||
|
|
||||||
// If no type is found for the given path, then ignore the diff
|
// If no type is found for the given path, then ignore the diff
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@ -114,5 +73,3 @@ const createEntitiesWriteStream = (options: IEntitiesRestoreStreamOptions) => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export { createEntitiesWriteStream };
|
|
||||||
|
@ -4,7 +4,7 @@ import * as webStream from 'stream/web';
|
|||||||
import { stat, createReadStream, ReadStream } from 'fs-extra';
|
import { stat, createReadStream, ReadStream } from 'fs-extra';
|
||||||
import type { Core } from '@strapi/types';
|
import type { Core } from '@strapi/types';
|
||||||
|
|
||||||
import type { IAsset } from '../../../../types';
|
import type { IAsset, IFile } from '../../../../types';
|
||||||
|
|
||||||
function getFileStream(
|
function getFileStream(
|
||||||
filepath: string,
|
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
|
* 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) {
|
for await (const file of stream) {
|
||||||
const isLocalProvider = file.provider === 'local';
|
const isLocalProvider = file.provider === 'local';
|
||||||
|
if (!isLocalProvider) {
|
||||||
|
await signFile(file);
|
||||||
|
}
|
||||||
const filepath = isLocalProvider ? join(strapi.dirs.static.public, file.url) : file.url;
|
const filepath = isLocalProvider ? join(strapi.dirs.static.public, file.url) : file.url;
|
||||||
const stats = await getFileStats(filepath, strapi, isLocalProvider);
|
const stats = await getFileStats(filepath, strapi, isLocalProvider);
|
||||||
const stream = getFileStream(filepath, strapi, isLocalProvider);
|
const stream = getFileStream(filepath, strapi, isLocalProvider);
|
||||||
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
@ -1,8 +1,8 @@
|
|||||||
import _ from 'lodash';
|
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 { 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<
|
type LoadedComponents<TUID extends UID.Schema> = Data.Entity<
|
||||||
TUID,
|
TUID,
|
||||||
@ -477,6 +477,55 @@ const deleteComponent = async <TUID extends UID.Component>(
|
|||||||
await strapi.db.query(uid).delete({ where: { id: componentToDelete.id } });
|
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 {
|
export {
|
||||||
omitComponentData,
|
omitComponentData,
|
||||||
getComponents,
|
getComponents,
|
||||||
@ -484,4 +533,5 @@ export {
|
|||||||
updateComponents,
|
updateComponents,
|
||||||
deleteComponents,
|
deleteComponents,
|
||||||
deleteComponent,
|
deleteComponent,
|
||||||
|
resolveComponentUID,
|
||||||
};
|
};
|
||||||
|
@ -25,7 +25,7 @@ const PurchaseReviewWorkflows = () => {
|
|||||||
content={formatMessage({
|
content={formatMessage({
|
||||||
id: 'Settings.review-workflows.not-available',
|
id: 'Settings.review-workflows.not-available',
|
||||||
defaultMessage:
|
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={
|
action={
|
||||||
<LinkButton
|
<LinkButton
|
||||||
|
@ -365,7 +365,7 @@ const PLUGIN_TEMPLATE = defineTemplate(async ({ logger, gitConfig, packagePath }
|
|||||||
...pkgJson.devDependencies,
|
...pkgJson.devDependencies,
|
||||||
'@types/react': '*',
|
'@types/react': '*',
|
||||||
'@types/react-dom': '*',
|
'@types/react-dom': '*',
|
||||||
'@types/styled-components': '5.1.26',
|
'@types/styled-components': '5.1.32',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { adminTsconfigFiles } = await import('./files/typescript');
|
const { adminTsconfigFiles } = await import('./files/typescript');
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@casl/ability": "6.5.0",
|
"@casl/ability": "6.5.0",
|
||||||
"@koa/cors": "3.4.3",
|
"@koa/cors": "5.0.0",
|
||||||
"@koa/router": "10.1.1",
|
"@koa/router": "10.1.1",
|
||||||
"@strapi/database": "5.0.0-beta.1",
|
"@strapi/database": "5.0.0-beta.1",
|
||||||
"@strapi/logger": "5.0.0-beta.1",
|
"@strapi/logger": "5.0.0-beta.1",
|
||||||
|
@ -36,13 +36,13 @@ describe('Upload service', () => {
|
|||||||
|
|
||||||
test('Replaces reserved and unsafe characters for URLs and files in hash', async () => {
|
test('Replaces reserved and unsafe characters for URLs and files in hash', async () => {
|
||||||
const fileData = {
|
const fileData = {
|
||||||
filename: 'File%&Näme<>:"|?*.png',
|
filename: 'File%&Näme.png',
|
||||||
type: 'image/png',
|
type: 'image/png',
|
||||||
size: 1000 * 1000,
|
size: 1000 * 1000,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(await uploadService.formatFileInfo(fileData)).toMatchObject({
|
expect(await uploadService.formatFileInfo(fileData)).toMatchObject({
|
||||||
name: 'File%&Näme<>:"|?*.png',
|
name: 'File%&Näme.png',
|
||||||
hash: expect.stringContaining('File_and_Naeme'),
|
hash: expect.stringContaining('File_and_Naeme'),
|
||||||
ext: '.png',
|
ext: '.png',
|
||||||
mime: 'image/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 () => {
|
test('Overrides name with fileInfo', async () => {
|
||||||
const fileData = {
|
const fileData = {
|
||||||
filename: 'File Name.png',
|
filename: 'File Name.png',
|
||||||
|
11
yarn.lock
11
yarn.lock
@ -4255,6 +4255,15 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
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":
|
"@koa/router@npm:10.1.1":
|
||||||
version: 10.1.1
|
version: 10.1.1
|
||||||
resolution: "@koa/router@npm:10.1.1"
|
resolution: "@koa/router@npm:10.1.1"
|
||||||
@ -8330,7 +8339,7 @@ __metadata:
|
|||||||
resolution: "@strapi/types@workspace:packages/core/types"
|
resolution: "@strapi/types@workspace:packages/core/types"
|
||||||
dependencies:
|
dependencies:
|
||||||
"@casl/ability": "npm:6.5.0"
|
"@casl/ability": "npm:6.5.0"
|
||||||
"@koa/cors": "npm:3.4.3"
|
"@koa/cors": "npm:5.0.0"
|
||||||
"@koa/router": "npm:10.1.1"
|
"@koa/router": "npm:10.1.1"
|
||||||
"@strapi/database": "npm:5.0.0-beta.1"
|
"@strapi/database": "npm:5.0.0-beta.1"
|
||||||
"@strapi/logger": "npm:5.0.0-beta.1"
|
"@strapi/logger": "npm:5.0.0-beta.1"
|
||||||
|
Loading…
x
Reference in New Issue
Block a user