mirror of
https://github.com/strapi/strapi.git
synced 2025-06-27 00:41:25 +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
|
||||
|
||||
- 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.
|
||||
|
@ -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.
|
||||
|
@ -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 */ `
|
||||
|
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`.
|
||||
|
||||
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.
|
||||
|
@ -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.
|
||||
|
@ -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 <-> 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 <-> 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 <-> 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)
|
||||
|
@ -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 = {
|
||||
|
@ -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",
|
||||
|
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.waitForURL('**/admin');
|
||||
await expect(page).toHaveTitle('Homepage | Strapi');
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 };
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
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);
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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) => {
|
||||
|
@ -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',
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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",
|
||||
|
@ -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 };
|
||||
|
@ -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);
|
||||
|
@ -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 { 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,
|
||||
};
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
|
@ -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",
|
||||
|
@ -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',
|
||||
|
11
yarn.lock
11
yarn.lock
@ -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"
|
||||
|
Loading…
x
Reference in New Issue
Block a user