Merge branch 'develop' into v5/main

This commit is contained in:
Josh 2024-01-22 13:39:48 +00:00
commit 79aef88064
40 changed files with 907 additions and 404 deletions

View File

@ -102,9 +102,7 @@ describe('Role CRUD End to End', () => {
"locales",
],
"label": "Publish",
"subjects": [
"plugin::users-permissions.user",
],
"subjects": [],
},
],
[
@ -179,6 +177,48 @@ describe('Role CRUD End to End', () => {
"plugin": "content-manager",
"subCategory": "single types",
},
{
"action": "plugin::content-releases.create",
"displayName": "Create",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-releases.create-action",
"displayName": "Add an entry to a release",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-releases.delete",
"displayName": "Delete",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-releases.delete-action",
"displayName": "Remove an entry from a release",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-releases.publish",
"displayName": "Publish",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-releases.read",
"displayName": "Read",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-releases.update",
"displayName": "Edit",
"plugin": "content-releases",
"subCategory": "general",
},
{
"action": "plugin::content-type-builder.read",
"displayName": "Read",
@ -599,9 +639,7 @@ describe('Role CRUD End to End', () => {
"locales",
],
"label": "Publish",
"subjects": [
"plugin::users-permissions.user",
],
"subjects": [],
},
],
[],

View File

@ -47,7 +47,7 @@
"prettier:code": "prettier --cache --cache-strategy content \"**/*.{js,ts,jsx,tsx,json}\"",
"prettier:other": "prettier --cache --cache-strategy content \"**/*.{md,mdx,css,scss,yaml,yml}\"",
"prettier:write": "prettier --write \"**/*.{js,ts,jsx,tsx,json,md,mdx,css,scss,yaml,yml}\"",
"setup": "yarn && yarn clean && yarn build",
"setup": "yarn && yarn clean && yarn build --skip-nx-cache",
"test:api": "node test/scripts/run-api-tests.js",
"test:clean": "rimraf ./coverage",
"test:cli": "node test/scripts/run-cli-tests.js",

View File

@ -0,0 +1,59 @@
import { getYupInnerErrors } from '@strapi/helper-plugin';
import { Schema, Entity as StrapiEntity, Attribute } from '@strapi/types';
import { ValidationError } from 'yup';
import { createYupSchema } from '../content-manager/utils/validation';
export interface Entity {
id: StrapiEntity.ID;
createdAt: string;
updatedAt: string;
}
interface ValidateOptions {
contentType: Schema.ContentType;
components: {
[key: Schema.Component['uid']]: Schema.Component;
};
isCreatingEntry?: boolean;
}
/**
* @alpha - This hook is not stable and likely to change. Use at your own risk.
*/
export function useDocument() {
/**
* @TODO: Ideally, we should get the contentType and components schemas from the redux store
* But at the moment the store is populated only inside the content-manager by useContentManagerInitData
* So, we need to receive the content type schema and the components to use the function
*/
const validate = (
entry: Entity & { [key: string]: Attribute.Any },
{ contentType, components, isCreatingEntry = false }: ValidateOptions
) => {
const schema = createYupSchema(
// @ts-expect-error - @TODO: createYupSchema types need to be revisited
contentType,
{ components },
{ isCreatingEntry, isDraft: false, isJSONTestDisabled: true }
);
try {
schema.validateSync(entry, { abortEarly: false });
return {
errors: {},
};
} catch (error) {
if (error instanceof ValidationError) {
return {
errors: getYupInnerErrors(error),
};
}
throw error;
}
};
return { validate };
}

View File

@ -2,3 +2,5 @@ export * from './render';
export type { Store } from './core/store/configure';
export type { SanitizedAdminUser } from '../../shared/contracts/shared';
export { useDocument as unstable_useDocument } from './hooks/useDocument';

View File

@ -59,6 +59,7 @@ interface TabQuery {
}
const MarketplacePage = () => {
const tabRef = React.useRef<any>(null);
const { formatMessage } = useIntl();
const { trackUsage } = useTracking();
const toggleNotification = useNotification();
@ -103,6 +104,16 @@ const MarketplacePage = () => {
pagination,
} = useMarketplaceData({ npmPackageType, debouncedSearch, query, tabQuery });
const indexOfNpmPackageType = ['plugin', 'provider'].indexOf(npmPackageType);
// TODO: Replace this solution with v2 of the Design System
// Check if the active tab index changes and call the handler of the ref to update the tab group component
React.useEffect(() => {
if (tabRef.current) {
tabRef.current._handlers.setSelectedTabIndex(indexOfNpmPackageType);
}
}, [indexOfNpmPackageType]);
if (!isOnline) {
return <OfflineLayout />;
}
@ -173,8 +184,9 @@ const MarketplacePage = () => {
})}
id="tabs"
variant="simple"
initialSelectedTabIndex={['plugin', 'provider'].indexOf(npmPackageType)}
initialSelectedTabIndex={indexOfNpmPackageType}
onTabChange={handleTabChange}
ref={tabRef}
>
<Flex justifyContent="space-between" paddingBottom={4}>
<Tabs>

View File

@ -6,7 +6,9 @@ const auditLogsService = adminApi.injectEndpoints({
getAuditLogs: builder.query<AuditLogs.GetAll.Response, AuditLogs.GetAll.Request['query']>({
query: (params) => ({
url: `/admin/audit-logs`,
params,
config: {
params,
},
}),
}),
getAuditLog: builder.query<AuditLogs.Get.Response, AuditLogs.Get.Params['id']>({

View File

@ -354,12 +354,14 @@ export const CMReleasesContainer = () => {
<Typography fontSize={2} fontWeight="bold" variant="omega" textColor="neutral700">
{release.name}
</Typography>
<ReleaseActionMenu.Root hasTriggerBorder>
<ReleaseActionMenu.DeleteReleaseActionItem
releaseId={release.id}
actionId={release.action.id}
/>
</ReleaseActionMenu.Root>
<CheckPermissions permissions={PERMISSIONS.deleteAction}>
<ReleaseActionMenu.Root hasTriggerBorder>
<ReleaseActionMenu.DeleteReleaseActionItem
releaseId={release.id}
actionId={release.action.id}
/>
</ReleaseActionMenu.Root>
</CheckPermissions>
</Flex>
</Flex>
);

View File

@ -6,20 +6,16 @@ import { CheckPermissions, useAPIErrorHandler, useNotification } from '@strapi/h
import { Cross, More, Pencil } from '@strapi/icons';
import { isAxiosError } from 'axios';
import { useIntl } from 'react-intl';
import { useSelector, TypedUseSelectorHook } from 'react-redux';
import { NavLink } from 'react-router-dom';
import styled from 'styled-components';
import { DeleteReleaseAction, ReleaseAction } from '../../../shared/contracts/release-actions';
import { PERMISSIONS } from '../constants';
import { useDeleteReleaseActionMutation } from '../services/release';
import { useTypedSelector } from '../store/hooks';
import type { Store } from '@strapi/admin/strapi-admin';
import type { Permission } from '@strapi/helper-plugin';
type RootState = ReturnType<Store['getState']>;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
const StyledMenuItem = styled(Menu.Item)<{ variant?: 'neutral' | 'danger' }>`
&:hover {
background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]};

View File

@ -12,10 +12,7 @@ import type { Plugin } from '@strapi/types';
const admin: Plugin.Config.AdminInput = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
register(app: any) {
if (
window.strapi.features.isEnabled('cms-content-releases') &&
window.strapi.future.isEnabled('contentReleases')
) {
if (window.strapi.features.isEnabled('cms-content-releases')) {
app.addMenuLink({
to: `plugins/${pluginId}`,
icon: PaperPlane,

View File

@ -1,5 +1,6 @@
import * as React from 'react';
import { unstable_useDocument } from '@strapi/admin/strapi-admin';
import {
Button,
ContentLayout,
@ -16,6 +17,7 @@ import {
SingleSelect,
SingleSelectOption,
Icon,
Tooltip,
} from '@strapi/design-system';
import { LinkButton } from '@strapi/design-system/v2';
import {
@ -33,7 +35,7 @@ import {
useRBAC,
AnErrorOccurred,
} from '@strapi/helper-plugin';
import { ArrowLeft, CheckCircle, More, Pencil, Trash } from '@strapi/icons';
import { ArrowLeft, CheckCircle, More, Pencil, Trash, CrossCircle } from '@strapi/icons';
import { useIntl } from 'react-intl';
import { useParams, useNavigate, Link as ReactRouterLink, Navigate } from 'react-router-dom';
import styled from 'styled-components';
@ -51,12 +53,16 @@ import {
useUpdateReleaseActionMutation,
usePublishReleaseMutation,
useDeleteReleaseMutation,
releaseApi,
} from '../services/release';
import { useTypedDispatch } from '../store/hooks';
import type {
ReleaseAction,
ReleaseActionGroupBy,
ReleaseActionEntry,
} from '../../../shared/contracts/release-actions';
import type { Schema } from '@strapi/types';
/* -------------------------------------------------------------------------------------------------
* ReleaseDetailsLayout
@ -97,6 +103,10 @@ const TrashIcon = styled(Trash)`
}
`;
const TypographyMaxWidth = styled(Typography)`
max-width: 300px;
`;
interface PopoverButtonProps {
onClick?: (event: React.MouseEvent<HTMLElement>) => void;
disabled?: boolean;
@ -123,18 +133,49 @@ const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => {
};
interface EntryValidationTextProps {
status: ReleaseAction['entry']['status'];
action: ReleaseAction['type'];
schema: Schema.ContentType;
components: { [key: Schema.Component['uid']]: Schema.Component };
entry: ReleaseActionEntry;
}
const EntryValidationText = ({ status, action }: EntryValidationTextProps) => {
const EntryValidationText = ({ action, schema, components, entry }: EntryValidationTextProps) => {
const { formatMessage } = useIntl();
const { validate } = unstable_useDocument();
const { errors } = validate(entry, {
contentType: schema,
components,
isCreatingEntry: false,
});
if (Object.keys(errors).length > 0) {
const validationErrorsMessages = Object.entries(errors)
.map(([key, value]) =>
formatMessage(
{ id: `${value.id}.withField`, defaultMessage: value.defaultMessage },
{ field: key }
)
)
.join(' ');
return (
<Flex gap={2}>
<Icon color="danger600" as={CrossCircle} />
<Tooltip description={validationErrorsMessages}>
<TypographyMaxWidth textColor="danger600" variant="omega" fontWeight="semiBold" ellipsis>
{validationErrorsMessages}
</TypographyMaxWidth>
</Tooltip>
</Flex>
);
}
if (action == 'publish') {
return (
<Flex gap={2}>
<Icon color="success600" as={CheckCircle} />
{status === 'published' ? (
{entry.publishedAt ? (
<Typography textColor="success600" fontWeight="bold">
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-published',
@ -156,7 +197,7 @@ const EntryValidationText = ({ status, action }: EntryValidationTextProps) => {
return (
<Flex gap={2}>
<Icon color="success600" as={CheckCircle} />
{status === 'draft' ? (
{!entry.publishedAt ? (
<Typography textColor="success600" fontWeight="bold">
{formatMessage({
id: 'content-releases.pages.ReleaseDetails.entry-validation.already-unpublished',
@ -206,6 +247,7 @@ const ReleaseDetailsLayout = ({
const {
allowedActions: { canUpdate, canDelete },
} = useRBAC(PERMISSIONS);
const dispatch = useTypedDispatch();
const release = data?.data;
@ -250,6 +292,10 @@ const ReleaseDetailsLayout = ({
handleTogglePopover();
};
const handleRefresh = () => {
dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }]));
};
if (isLoadingDetails) {
return (
<Main aria-busy={isLoadingDetails}>
@ -364,6 +410,12 @@ const ReleaseDetailsLayout = ({
</ReleaseInfoWrapper>
</Popover>
)}
<Button size="S" variant="tertiary" onClick={handleRefresh}>
{formatMessage({
id: 'content-releases.header.actions.refresh',
defaultMessage: 'Refresh',
})}
</Button>
<CheckPermissions permissions={PERMISSIONS.publish}>
<Button
size="S"
@ -485,6 +537,8 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
const releaseActions = data?.data;
const releaseMeta = data?.meta;
const contentTypes = releaseMeta?.contentTypes || {};
const components = releaseMeta?.components || {};
if (isReleaseError || !release) {
const errorsArray = [];
@ -637,20 +691,18 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
</Table.Head>
<Table.LoadingBody />
<Table.Body>
{releaseActions[key].map(({ id, type, entry, contentType, locale }) => (
{releaseActions[key].map(({ id, contentType, locale, type, entry }) => (
<Tr key={id}>
<Td width="25%" maxWidth="200px">
<Typography ellipsis>{`${
entry.contentType.mainFieldValue || entry.id
contentType.mainFieldValue || entry.id
}`}</Typography>
</Td>
<Td width="10%">
<Typography>{`${
entry?.locale?.name ? entry.locale.name : '-'
}`}</Typography>
<Typography>{`${locale?.name ? locale.name : '-'}`}</Typography>
</Td>
<Td width="10%">
<Typography ellipsis>{entry.contentType.displayName || ''}</Typography>
<Typography>{contentType.displayName || ''}</Typography>
</Td>
<Td width="20%">
{release.releasedAt ? (
@ -680,15 +732,20 @@ const ReleaseDetailsBody = ({ releaseId }: ReleaseDetailsBodyProps) => {
{!release.releasedAt && (
<>
<Td width="20%" minWidth="200px">
<EntryValidationText status={entry.status} action={type} />
<EntryValidationText
action={type}
schema={contentTypes?.[contentType.uid]}
components={components}
entry={entry}
/>
</Td>
<Td>
<Flex justifyContent="flex-end">
<ReleaseActionMenu.Root>
<ReleaseActionMenu.ReleaseActionEntryLinkItem
contentTypeUid={contentType}
contentTypeUid={contentType.uid}
entryId={entry.id}
locale={locale}
locale={locale?.code}
/>
<ReleaseActionMenu.DeleteReleaseActionItem
releaseId={release.id}

View File

@ -179,6 +179,7 @@ const INITIAL_FORM_VALUES = {
} satisfies FormValues;
const ReleasesPage = () => {
const tabRef = React.useRef<any>(null);
const location = useLocation();
const [releaseModalShown, setReleaseModalShown] = React.useState(false);
const toggleNotification = useNotification();
@ -190,6 +191,8 @@ const ReleasesPage = () => {
const [createRelease, { isLoading: isSubmittingForm }] = useCreateReleaseMutation();
const { isLoading, isSuccess, isError } = response;
const activeTab = response?.currentData?.meta?.activeTab || 'pending';
const activeTabIndex = ['pending', 'done'].indexOf(activeTab);
// Check if we have some errors and show a notification to the user to explain the error
React.useEffect(() => {
@ -209,6 +212,14 @@ const ReleasesPage = () => {
}
}, [formatMessage, location?.state?.errors, navigate, toggleNotification]);
// TODO: Replace this solution with v2 of the Design System
// Check if the active tab index changes and call the handler of the ref to update the tab group component
React.useEffect(() => {
if (tabRef.current) {
tabRef.current._handlers.setSelectedTabIndex(activeTabIndex);
}
}, [activeTabIndex]);
const toggleAddReleaseModal = () => {
setReleaseModalShown((prev) => !prev);
};
@ -238,8 +249,6 @@ const ReleasesPage = () => {
});
};
const activeTab = response?.currentData?.meta?.activeTab || 'pending';
const handleAddRelease = async (values: FormValues) => {
const response = await createRelease({
name: values.name,
@ -280,8 +289,9 @@ const ReleasesPage = () => {
defaultMessage: 'Releases list',
})}
variant="simple"
initialSelectedTabIndex={['pending', 'done'].indexOf(activeTab)}
initialSelectedTabIndex={activeTabIndex}
onTabChange={handleTabChange}
ref={tabRef}
>
<Box paddingBottom={8}>
<Tabs>

View File

@ -18,6 +18,15 @@ jest.mock('@strapi/helper-plugin', () => ({
})),
}));
/**
* Mocking the useDocument hook to avoid validation errors for testing
*/
jest.mock('@strapi/admin/strapi-admin', () => ({
unstable_useDocument: jest
.fn()
.mockReturnValue({ validate: jest.fn().mockReturnValue({ errors: {} }) }),
}));
describe('Releases details page', () => {
it('renders the details page with no actions', async () => {
server.use(
@ -108,19 +117,19 @@ describe('Releases details page', () => {
// should show the entries
expect(
screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].contentType
.mainFieldValue
)
).toBeInTheDocument();
expect(
screen.getByRole('gridcell', {
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.contentType
name: mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].contentType
.displayName,
})
).toBeInTheDocument();
expect(
screen.getByText(
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].entry.locale.name
mockReleaseDetailsPageData.withActionsBodyData.data['Category'][0].locale.name
)
).toBeInTheDocument();

View File

@ -98,19 +98,19 @@ const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
uid: 'api::category.category',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: {
id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
},
},
],
@ -122,6 +122,8 @@ const RELEASE_WITH_ACTIONS_BODY_MOCK_DATA = {
total: 1,
pageCount: 1,
},
contentTypes: {},
components: {},
},
};
@ -134,39 +136,39 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
{
id: 3,
type: 'publish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
uid: 'api::category.category',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: {
id: 1,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'draft',
publishedAt: null,
},
},
{
id: 4,
type: 'unpublish',
contentType: 'api::category.category',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Category',
mainFieldValue: 'cat2',
uid: 'api::category.category',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: {
id: 2,
contentType: {
displayName: 'Category',
mainFieldValue: 'cat2',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
publishedAt: '2023-12-05T09:03:57.155Z',
},
},
],
@ -174,20 +176,20 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
{
id: 5,
type: 'publish',
contentType: 'api::address.address',
createdAt: '2023-12-05T09:03:57.155Z',
updatedAt: '2023-12-05T09:03:57.155Z',
contentType: {
displayName: 'Address',
mainFieldValue: 'add1',
uid: 'api::address.address',
},
locale: {
name: 'English (en)',
code: 'en',
},
entry: {
id: 1,
contentType: {
displayName: 'Address',
mainFieldValue: 'add1',
},
locale: {
name: 'English (en)',
code: 'en',
},
status: 'published',
publishedAt: '2023-12-05T09:03:57.155Z',
},
},
],
@ -199,6 +201,8 @@ const RELEASE_WITH_MULTIPLE_ACTIONS_BODY_MOCK_DATA = {
total: 1,
pageCount: 1,
},
contentTypes: {},
components: {},
},
};

View File

@ -0,0 +1,11 @@
import { Dispatch } from '@reduxjs/toolkit';
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import type { Store } from '@strapi/admin/strapi-admin';
type RootState = ReturnType<Store['getState']>;
const useTypedDispatch: () => Dispatch = useDispatch;
const useTypedSelector: TypedUseSelectorHook<RootState> = useSelector;
export { useTypedSelector, useTypedDispatch };

View File

@ -68,6 +68,7 @@
"yup": "0.32.9"
},
"devDependencies": {
"@strapi/admin": "4.17.1",
"@strapi/admin-test-utils": "4.17.1",
"@strapi/pack-up": "workspace:*",
"@strapi/strapi": "4.17.1",
@ -84,6 +85,7 @@
"typescript": "5.2.2"
},
"peerDependencies": {
"@strapi/admin": "^4.19.0",
"@strapi/strapi": "^4.15.1",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",

View File

@ -22,9 +22,9 @@ describe('register', () => {
addDestroyListenerCallback: jest.fn(),
})),
})),
eventHub: {
on: jest.fn(),
},
hook: jest.fn(() => ({
register: jest.fn(),
})),
admin: {
services: {
permission: {

View File

@ -1,21 +1,18 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import type { LoadedStrapi, Entity as StrapiEntity } from '@strapi/types';
import { RELEASE_ACTION_MODEL_UID } from './constants';
export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
if (
strapi.ee.features.isEnabled('cms-content-releases') &&
strapi.features.future.isEnabled('contentReleases')
) {
if (strapi.ee.features.isEnabled('cms-content-releases')) {
// Clean up release-actions when an entry is deleted
strapi.db.lifecycles.subscribe({
afterDelete(event) {
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
const { model, result } = event;
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
if (model.kind === 'collectionType' && model.options.draftAndPublish) {
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
const { id } = result;
strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {
target_type: model.uid,
@ -24,26 +21,21 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
});
}
},
/**
* deleteMany hook doesn't return the deleted entries ids
* so we need to fetch them before deleting the entries to save the ids on our state
*/
async beforeDeleteMany(event) {
const { model, params } = event;
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
if (model.kind === 'collectionType' && model.options.draftAndPublish) {
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
const { where } = params;
const entriesToDelete = await strapi.db
.query(model.uid)
.findMany({ select: ['id'], where });
event.state.entriesToDelete = entriesToDelete;
}
},
/**
* We delete the release actions related to deleted entries
* We make this only after deleteMany is succesfully executed to avoid errors
@ -51,7 +43,6 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
async afterDeleteMany(event) {
const { model, state } = event;
const entriesToDelete = state.entriesToDelete;
if (entriesToDelete) {
await strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {

View File

@ -1,14 +1,16 @@
import releaseController from '../release';
const mockFindPage = jest.fn();
const mockFindManyForContentTypeEntry = jest.fn();
const mockFindManyWithContentTypeEntryAttached = jest.fn();
const mockFindManyWithoutContentTypeEntryAttached = jest.fn();
const mockCountActions = jest.fn();
jest.mock('../../utils', () => ({
getService: jest.fn(() => ({
findOne: jest.fn(() => ({ id: 1 })),
findPage: mockFindPage,
findManyForContentTypeEntry: mockFindManyForContentTypeEntry,
findManyWithContentTypeEntryAttached: mockFindManyWithContentTypeEntryAttached,
findManyWithoutContentTypeEntryAttached: mockFindManyWithoutContentTypeEntryAttached,
countActions: mockCountActions,
getContentTypesDataForActions: jest.fn(),
})),
@ -19,7 +21,7 @@ describe('Release controller', () => {
describe('findMany', () => {
it('should call findPage', async () => {
mockFindPage.mockResolvedValue({ results: [], pagination: {} });
mockFindManyForContentTypeEntry.mockResolvedValue([]);
mockFindManyWithContentTypeEntryAttached.mockResolvedValue([]);
const userAbility = {
can: jest.fn(),
};
@ -53,9 +55,9 @@ describe('Release controller', () => {
expect(mockFindPage).toHaveBeenCalled();
});
it('should call findManyForContentTypeEntry', async () => {
it('should call findManyWithoutContentTypeEntryAttached', async () => {
mockFindPage.mockResolvedValue({ results: [], pagination: {} });
mockFindManyForContentTypeEntry.mockResolvedValue([]);
mockFindManyWithContentTypeEntryAttached.mockResolvedValue([]);
const userAbility = {
can: jest.fn(),
};
@ -86,7 +88,7 @@ describe('Release controller', () => {
// @ts-expect-error partial context
await releaseController.findMany(ctx);
expect(mockFindManyForContentTypeEntry).toHaveBeenCalled();
expect(mockFindManyWithoutContentTypeEntryAttached).toHaveBeenCalled();
});
});
describe('create', () => {
@ -165,6 +167,24 @@ describe('Release controller', () => {
},
},
},
plugins: {
// @ts-expect-error Ignore missing properties
'content-manager': {
services: {
'content-types': {
findAllContentTypes: jest
.fn()
.mockReturnValue([
{ uid: 'api::contentTypeA.contentTypeA' },
{ uid: 'api::contentTypeB.contentTypeB' },
]),
},
components: {
findAllComponents: jest.fn().mockReturnValue([{ uid: 'component.component' }]),
},
},
},
},
};
});

View File

@ -1,5 +1,6 @@
import type Koa from 'koa';
import { mapAsync } from '@strapi/utils';
import {
validateReleaseAction,
validateReleaseActionUpdateSchema,
@ -41,12 +42,48 @@ const releaseActionController = {
sort: query.groupBy === 'action' ? 'type' : query.groupBy,
...query,
});
const groupedData = await releaseService.groupActions(results, query.groupBy);
/**
* Release actions can be related to entries of different content types.
* We need to sanitize the entry output according to that content type.
* So, we group the sanitized output function by content type.
*/
const contentTypeOutputSanitizers = results.reduce((acc, action) => {
if (acc[action.contentType]) {
return acc;
}
const contentTypePermissionsManager =
strapi.admin.services.permission.createPermissionsManager({
ability: ctx.state.userAbility,
model: action.contentType,
});
acc[action.contentType] = contentTypePermissionsManager.sanitizeOutput;
return acc;
}, {});
/**
* sanitizeOutput doesn't work if you use it directly on the Release Action model, it doesn't sanitize the entries
* So, we need to sanitize manually each entry inside a Release Action
*/
const sanitizedResults = await mapAsync(results, async (action) => ({
...action,
entry: await contentTypeOutputSanitizers[action.contentType](action.entry),
}));
const groupedData = await releaseService.groupActions(sanitizedResults, query.groupBy);
const contentTypes = releaseService.getContentTypeModelsFromActions(results);
const components = await releaseService.getAllComponents();
ctx.body = {
data: groupedData,
meta: {
pagination,
contentTypes,
components,
},
};
},

View File

@ -40,9 +40,9 @@ const releaseController = {
const hasEntryAttached: GetContentTypeEntryReleases.Request['query']['hasEntryAttached'] =
typeof query.hasEntryAttached === 'string' ? JSON.parse(query.hasEntryAttached) : false;
const data = await releaseService.findManyForContentTypeEntry(contentTypeUid, entryId, {
hasEntryAttached,
});
const data = hasEntryAttached
? await releaseService.findManyWithContentTypeEntryAttached(contentTypeUid, entryId)
: await releaseService.findManyWithoutContentTypeEntryAttached(contentTypeUid, entryId);
ctx.body = { data };
} else {

View File

@ -5,13 +5,9 @@ import { contentTypes } from './content-types';
import { services } from './services';
import { controllers } from './controllers';
import { routes } from './routes';
import { getService } from './utils';
const getPlugin = () => {
if (
strapi.ee.features.isEnabled('cms-content-releases') &&
strapi.features.future.isEnabled('contentReleases')
) {
if (strapi.ee.features.isEnabled('cms-content-releases')) {
return {
register,
bootstrap,
@ -19,14 +15,6 @@ const getPlugin = () => {
services,
controllers,
routes,
destroy() {
if (
strapi.ee.features.isEnabled('cms-content-releases') &&
strapi.features.future.isEnabled('contentReleases')
) {
getService('event-manager').destroyAllListeners();
}
},
};
}

View File

@ -0,0 +1,53 @@
import type { Schema } from '@strapi/types';
import { contentTypes as contentTypesUtils, mapAsync } from '@strapi/utils';
import { difference, keys } from 'lodash';
import { RELEASE_ACTION_MODEL_UID } from '../constants';
interface Input {
oldContentTypes: Record<string, Schema.ContentType>;
contentTypes: Record<string, Schema.ContentType>;
}
export async function deleteActionsOnDisableDraftAndPublish({
oldContentTypes,
contentTypes,
}: Input) {
if (!oldContentTypes) {
return;
}
for (const uid in contentTypes) {
if (!oldContentTypes[uid]) {
continue;
}
const oldContentType = oldContentTypes[uid];
const contentType = contentTypes[uid];
if (
contentTypesUtils.hasDraftAndPublish(oldContentType) &&
!contentTypesUtils.hasDraftAndPublish(contentType)
) {
await strapi.db
?.queryBuilder(RELEASE_ACTION_MODEL_UID)
.delete()
.where({ contentType: uid })
.execute();
}
}
}
export async function deleteActionsOnDeleteContentType({ oldContentTypes, contentTypes }: Input) {
const deletedContentTypes = difference(keys(oldContentTypes), keys(contentTypes)) ?? [];
if (deletedContentTypes.length) {
await mapAsync(deletedContentTypes, async (deletedContentTypeUID: unknown) => {
return strapi.db
?.queryBuilder(RELEASE_ACTION_MODEL_UID)
.delete()
.where({ contentType: deletedContentTypeUID })
.execute();
});
}
}

View File

@ -1,35 +1,17 @@
/* eslint-disable @typescript-eslint/no-var-requires */
import type { LoadedStrapi } from '@strapi/types';
import { ACTIONS } from './constants';
import { getService } from './utils';
import { ACTIONS } from './constants';
import {
deleteActionsOnDeleteContentType,
deleteActionsOnDisableDraftAndPublish,
} from './migrations';
export const register = async ({ strapi }: { strapi: LoadedStrapi }) => {
if (
strapi.ee.features.isEnabled('cms-content-releases') &&
strapi.features.future.isEnabled('contentReleases')
) {
if (strapi.ee.features.isEnabled('cms-content-releases')) {
await strapi.admin.services.permission.actionProvider.registerMany(ACTIONS);
const releaseActionService = getService('release-action', { strapi });
const eventManager = getService('event-manager', { strapi });
// Clean up release-actions when draft and publish is disabled
const destroyContentTypeUpdateListener = strapi.eventHub.on(
'content-type.update',
async ({ contentType }) => {
if (contentType.schema.options.draftAndPublish === false) {
await releaseActionService.deleteManyForContentType(contentType.uid);
}
}
);
eventManager.addDestroyListenerCallback(destroyContentTypeUpdateListener);
// Clean up release-actions when a content-type is deleted
const destroyContentTypeDeleteListener = strapi.eventHub.on(
'content-type.delete',
async ({ contentType }) => {
await releaseActionService.deleteManyForContentType(contentType.uid);
}
);
eventManager.addDestroyListenerCallback(destroyContentTypeDeleteListener);
strapi.hook('strapi::content-types.beforeSync').register(deleteActionsOnDisableDraftAndPublish);
strapi.hook('strapi::content-types.afterSync').register(deleteActionsOnDeleteContentType);
}
};

View File

@ -1,23 +0,0 @@
import createReleaseActionService from '../release-action';
describe('Release Action service', () => {
it('deletes all release actions given a content type uid', async () => {
const strapiMock = {
db: {
query: jest.fn().mockReturnValue({
deleteMany: jest.fn(),
}),
},
};
// @ts-expect-error Ignore missing properties
const releaseActionService = createReleaseActionService({ strapi: strapiMock });
await releaseActionService.deleteManyForContentType('api::test.test');
expect(strapiMock.db.query().deleteMany).toHaveBeenCalledWith({
where: {
target_type: 'api::test.test',
},
});
});
});

View File

@ -95,6 +95,7 @@ describe('Release Validation service', () => {
);
});
});
describe('validatePendingReleasesLimit', () => {
it('should throw an error if the default pending release limit has been reached', () => {
const strapiMock = {
@ -181,4 +182,42 @@ describe('Release Validation service', () => {
await expect(releaseValidationService.validatePendingReleasesLimit()).resolves.not.toThrow();
});
});
describe('validateUniqueNameForPendingRelease', () => {
it('should throw an error if a release with the same name already exists', async () => {
const strapiMock = {
...baseStrapiMock,
entityService: {
findMany: jest.fn().mockReturnValue([
{
name: 'release1',
},
]),
},
};
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: strapiMock });
await expect(
releaseValidationService.validateUniqueNameForPendingRelease('release1')
).rejects.toThrow('Release with name release1 already exists');
});
it('should pass if a release with the same name does NOT already exist', async () => {
const strapiMock = {
...baseStrapiMock,
entityService: {
findMany: jest.fn().mockReturnValue([]),
},
};
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: strapiMock });
await expect(
releaseValidationService.validateUniqueNameForPendingRelease('release1')
).resolves.not.toThrow();
});
});
});

View File

@ -238,16 +238,29 @@ describe('release service', () => {
const mockPublishMany = jest.fn();
const mockUnpublishMany = jest.fn();
const servicesMock = {
'entity-manager': {
publishMany: mockPublishMany,
unpublishMany: mockUnpublishMany,
},
'populate-builder': () => ({
default: jest.fn().mockReturnThis(),
populateDeep: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnThis(),
}),
};
const strapiMock = {
...baseStrapiMock,
db: {
transaction: jest.fn().mockImplementation((cb) => cb()),
},
plugin: jest.fn().mockReturnValue({
service: jest.fn().mockReturnValue({
publishMany: mockPublishMany,
unpublishMany: mockUnpublishMany,
}),
service: jest
.fn()
.mockImplementation((service: 'entity-manager' | 'populate-builder') => {
return servicesMock[service];
}),
}),
entityService: {
findOne: jest.fn().mockReturnValue({
@ -265,6 +278,7 @@ describe('release service', () => {
},
],
}),
findMany: jest.fn(),
update: jest.fn().mockReturnValue({}),
},
};
@ -272,15 +286,36 @@ describe('release service', () => {
// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });
strapiMock.entityService.findMany.mockReturnValueOnce([
{
contentType: 'contentType',
type: 'publish',
entry: { id: 1 },
},
]);
strapiMock.entityService.findMany.mockReturnValueOnce([
{
contentType: 'contentType',
type: 'unpublish',
entry: { id: 2 },
},
]);
await releaseService.publish(1);
expect(mockPublishMany).toHaveBeenCalledWith([{ id: 1 }], 'contentType');
expect(mockUnpublishMany).toHaveBeenCalledWith([{ id: 2 }], 'contentType');
expect(mockPublishMany).toHaveBeenCalledWith(
[{ contentType: 'contentType', entry: { id: 1 }, type: 'publish' }],
'contentType'
);
expect(mockUnpublishMany).toHaveBeenCalledWith(
[{ contentType: 'contentType', entry: { id: 2 }, type: 'unpublish' }],
'contentType'
);
});
});
describe('findManyForContentTypeEntry', () => {
it('should format the return value correctly when hasEntryAttached is true', async () => {
describe('findManyWithContentTypeEntryAttached', () => {
it('should format the return value correctly', async () => {
const strapiMock = {
...baseStrapiMock,
db: {
@ -294,12 +329,9 @@ describe('release service', () => {
// @ts-expect-error Ignore missing properties
const releaseService = createReleaseService({ strapi: strapiMock });
const releases = await releaseService.findManyForContentTypeEntry(
const releases = await releaseService.findManyWithContentTypeEntryAttached(
'api::contentType.contentType',
1,
{
hasEntryAttached: true,
}
1
);
expect(releases).toEqual([{ name: 'test release', action: { type: 'publish' } }]);
@ -407,38 +439,38 @@ describe('release service', () => {
contentTypeA: [
{
id: 1,
locale: 'en',
contentType: 'api::contentTypeA.contentTypeA',
contentType: {
displayName: 'contentTypeA',
mainFieldValue: 'test 1',
uid: 'api::contentTypeA.contentTypeA',
},
locale: {
code: 'en',
name: 'English (en)',
},
entry: {
id: 1,
contentType: {
displayName: 'contentTypeA',
mainFieldValue: 'test 1',
},
locale: {
code: 'en',
name: 'English (en)',
},
status: 'published',
name: 'test 1',
publishedAt: '2021-01-01',
},
},
],
contentTypeB: [
{
id: 2,
locale: 'fr',
contentType: 'api::contentTypeB.contentTypeB',
contentType: {
displayName: 'contentTypeB',
mainFieldValue: 'test 2',
uid: 'api::contentTypeB.contentTypeB',
},
locale: {
code: 'fr',
name: 'French (fr)',
},
entry: {
id: 2,
contentType: {
displayName: 'contentTypeB',
mainFieldValue: 'test 2',
},
locale: {
code: 'fr',
name: 'French (fr)',
},
status: 'draft',
name: 'test 2',
publishedAt: null,
},
},
],

View File

@ -1,27 +0,0 @@
interface ReleaseEventServiceState {
destroyListenerCallbacks: (() => void)[];
}
const createEventManagerService = () => {
const state: ReleaseEventServiceState = {
destroyListenerCallbacks: [],
};
return {
addDestroyListenerCallback(destroyListenerCallback: () => void) {
state.destroyListenerCallbacks.push(destroyListenerCallback);
},
destroyAllListeners() {
if (!state.destroyListenerCallbacks.length) {
return;
}
state.destroyListenerCallbacks.forEach((destroyListenerCallback) => {
destroyListenerCallback();
});
},
};
};
export default createEventManagerService;

View File

@ -1,11 +1,7 @@
import releaseAction from './release-action';
import release from './release';
import releaseValidation from './validation';
import eventManager from './event-manager';
export const services = {
release,
'release-action': releaseAction,
'release-validation': releaseValidation,
'event-manager': eventManager,
};

View File

@ -1,15 +0,0 @@
import type { LoadedStrapi } from '@strapi/types';
import { RELEASE_ACTION_MODEL_UID } from '../constants';
import type { ReleaseAction } from '../../../shared/contracts/release-actions';
const createReleaseActionService = ({ strapi }: { strapi: LoadedStrapi }) => ({
async deleteManyForContentType(contentTypeUid: ReleaseAction['contentType']) {
return strapi.db.query(RELEASE_ACTION_MODEL_UID).deleteMany({
where: {
target_type: contentTypeUid,
},
});
},
});
export default createReleaseActionService;

View File

@ -1,6 +1,6 @@
import { setCreatorFields, errors } from '@strapi/utils';
import type { LoadedStrapi, EntityService, UID } from '@strapi/types';
import type { LoadedStrapi, EntityService, UID, Schema } from '@strapi/types';
import _ from 'lodash/fp';
@ -26,7 +26,7 @@ import type {
import type { Entity, UserInfo } from '../../../shared/types';
import { getService } from '../utils';
interface Locale extends Entity {
export interface Locale extends Entity {
name: string;
code: string;
}
@ -38,13 +38,13 @@ type LocaleDictionary = {
const getGroupName = (queryValue?: ReleaseActionGroupBy) => {
switch (queryValue) {
case 'contentType':
return 'entry.contentType.displayName';
return 'contentType.displayName';
case 'action':
return 'type';
case 'locale':
return _.getOr('No locale', 'entry.locale.name');
return _.getOr('No locale', 'locale.name');
default:
return 'entry.contentType.displayName';
return 'contentType.displayName';
}
};
@ -52,7 +52,15 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
async create(releaseData: CreateRelease.Request['body'], { user }: { user: UserInfo }) {
const releaseWithCreatorFields = await setCreatorFields({ user })(releaseData);
await getService('release-validation', { strapi }).validatePendingReleasesLimit();
const { validatePendingReleasesLimit, validateUniqueNameForPendingRelease } = getService(
'release-validation',
{ strapi }
);
await Promise.all([
validatePendingReleasesLimit(),
validateUniqueNameForPendingRelease(releaseWithCreatorFields.name),
]);
return strapi.entityService.create(RELEASE_MODEL_UID, {
data: releaseWithCreatorFields,
@ -79,60 +87,80 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
});
},
async findManyForContentTypeEntry(
async findManyWithContentTypeEntryAttached(
contentTypeUid: GetContentTypeEntryReleases.Request['query']['contentTypeUid'],
entryId: GetContentTypeEntryReleases.Request['query']['entryId'],
{
hasEntryAttached,
}: { hasEntryAttached?: GetContentTypeEntryReleases.Request['query']['hasEntryAttached'] } = {
hasEntryAttached: false,
}
entryId: GetContentTypeEntryReleases.Request['query']['entryId']
) {
const whereActions = hasEntryAttached
? {
// Find all Releases where the content type entry is present
actions: {
target_type: contentTypeUid,
target_id: entryId,
},
}
: {
// Find all Releases where the content type entry is not present
$or: [
{
$not: {
actions: {
target_type: contentTypeUid,
target_id: entryId,
},
},
},
{
actions: null,
},
],
};
const populateAttachedAction = hasEntryAttached
? {
// Filter the action to get only the content type entry
actions: {
where: {
target_type: contentTypeUid,
target_id: entryId,
},
},
}
: {};
const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
...whereActions,
actions: {
target_type: contentTypeUid,
target_id: entryId,
},
releasedAt: {
$null: true,
},
},
populate: {
...populateAttachedAction,
// Filter the action to get only the content type entry
actions: {
where: {
target_type: contentTypeUid,
target_id: entryId,
},
},
},
});
return releases.map((release) => {
if (release.actions?.length) {
const [actionForEntry] = release.actions;
// Remove the actions key to replace it with an action key
delete release.actions;
return {
...release,
action: actionForEntry,
};
}
return release;
});
},
async findManyWithoutContentTypeEntryAttached(
contentTypeUid: GetContentTypeEntryReleases.Request['query']['contentTypeUid'],
entryId: GetContentTypeEntryReleases.Request['query']['entryId']
) {
// We get the list of releases where the entry is present
const releasesRelated = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
releasedAt: {
$null: true,
},
actions: {
target_type: contentTypeUid,
target_id: entryId,
},
},
});
const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({
where: {
$or: [
{
id: {
$notIn: releasesRelated.map((release) => release.id),
},
},
{
actions: null,
},
],
releasedAt: {
$null: true,
},
},
});
@ -238,7 +266,9 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return strapi.entityService.findPage(RELEASE_ACTION_MODEL_UID, {
...query,
populate: {
entry: true,
entry: {
populate: '*',
},
},
filters: {
release: releaseId,
@ -268,14 +298,11 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return {
...action,
entry: {
id: action.entry.id,
contentType: {
displayName,
mainFieldValue: action.entry[mainField],
},
locale: action.locale ? allLocalesDictionary[action.locale] : null,
status: action.entry.publishedAt ? 'published' : 'draft',
locale: action.locale ? allLocalesDictionary[action.locale] : null,
contentType: {
displayName,
mainFieldValue: action.entry[mainField],
uid: action.contentType,
},
};
});
@ -318,6 +345,47 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
return contentTypesData;
},
getContentTypeModelsFromActions(actions: ReleaseAction[]) {
const contentTypeUids = actions.reduce<ReleaseAction['contentType'][]>((acc, action) => {
if (!acc.includes(action.contentType)) {
acc.push(action.contentType);
}
return acc;
}, []);
const contentTypeModelsMap = contentTypeUids.reduce(
(
acc: { [key: ReleaseAction['contentType']]: Schema.ContentType },
contentTypeUid: ReleaseAction['contentType']
) => {
acc[contentTypeUid] = strapi.getModel(contentTypeUid);
return acc;
},
{}
);
return contentTypeModelsMap;
},
async getAllComponents() {
const contentManagerComponentsService = strapi.plugin('content-manager').service('components');
const components = await contentManagerComponentsService.findAllComponents();
const componentsMap = components.reduce(
(acc: { [key: Schema.Component['uid']]: Schema.Component }, component: Schema.Component) => {
acc[component.uid] = component;
return acc;
},
{}
);
return componentsMap;
},
async delete(releaseId: DeleteRelease.Request['params']['id']) {
const release = (await strapi.entityService.findOne(RELEASE_MODEL_UID, releaseId, {
populate: {
@ -360,7 +428,9 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
populate: {
actions: {
populate: {
entry: true,
entry: {
fields: ['id'],
},
},
},
},
@ -381,11 +451,12 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
/**
* We separate publish and unpublish actions group by content type
* And we keep only their ids to fetch them later to get all information needed
*/
const actions: {
[key: UID.ContentType]: {
publish: ReleaseAction['entry'][];
unpublish: ReleaseAction['entry'][];
entriestoPublishIds: ReleaseAction['entry']['id'][];
entriesToUnpublishIds: ReleaseAction['entry']['id'][];
};
} = {};
for (const action of releaseWithPopulatedActionEntries.actions) {
@ -393,31 +464,67 @@ const createReleaseService = ({ strapi }: { strapi: LoadedStrapi }) => ({
if (!actions[contentTypeUid]) {
actions[contentTypeUid] = {
publish: [],
unpublish: [],
entriestoPublishIds: [],
entriesToUnpublishIds: [],
};
}
if (action.type === 'publish') {
actions[contentTypeUid].publish.push(action.entry);
actions[contentTypeUid].entriestoPublishIds.push(action.entry.id);
} else {
actions[contentTypeUid].unpublish.push(action.entry);
actions[contentTypeUid].entriesToUnpublishIds.push(action.entry.id);
}
}
const entityManagerService = strapi.plugin('content-manager').service('entity-manager');
const populateBuilderService = strapi.plugin('content-manager').service('populate-builder');
// Only publish the release if all action updates are applied successfully to their entry, otherwise leave everything as is
await strapi.db.transaction(async () => {
for (const contentTypeUid of Object.keys(actions)) {
const { publish, unpublish } = actions[contentTypeUid as UID.ContentType];
// @ts-expect-error - populateBuilderService should be a function but is returning service
const populate = await populateBuilderService(contentTypeUid)
.populateDeep(Infinity)
.build();
if (publish.length > 0) {
await entityManagerService.publishMany(publish, contentTypeUid);
const { entriestoPublishIds, entriesToUnpublishIds } =
actions[contentTypeUid as UID.ContentType];
/**
* We need to get the populate entries to be able to publish without errors on components/relations/dynamicZones
* Considering that populate doesn't work well with morph relations we can't get the entries from the Release model
* So, we need to fetch them manually
*/
const entriesToPublish = (await strapi.entityService.findMany(
contentTypeUid as UID.ContentType,
{
filters: {
id: {
$in: entriestoPublishIds,
},
},
populate,
}
)) as Entity[];
const entriesToUnpublish = (await strapi.entityService.findMany(
contentTypeUid as UID.ContentType,
{
filters: {
id: {
$in: entriesToUnpublishIds,
},
},
populate,
}
)) as Entity[];
if (entriesToPublish.length > 0) {
await entityManagerService.publishMany(entriesToPublish, contentTypeUid);
}
if (unpublish.length > 0) {
await entityManagerService.unpublishMany(unpublish, contentTypeUid);
if (entriesToUnpublish.length > 0) {
await entityManagerService.unpublishMany(entriesToUnpublish, contentTypeUid);
}
}
});

View File

@ -1,6 +1,6 @@
import { errors } from '@strapi/utils';
import { LoadedStrapi } from '@strapi/types';
import type { Release } from '../../../shared/contracts/releases';
import type { Release, CreateRelease } from '../../../shared/contracts/releases';
import type { CreateReleaseAction } from '../../../shared/contracts/release-actions';
import { RELEASE_MODEL_UID } from '../constants';
@ -60,6 +60,22 @@ const createReleaseValidationService = ({ strapi }: { strapi: LoadedStrapi }) =>
throw new errors.ValidationError('You have reached the maximum number of pending releases');
}
},
async validateUniqueNameForPendingRelease(name: CreateRelease.Request['body']['name']) {
const pendingReleases = (await strapi.entityService.findMany(RELEASE_MODEL_UID, {
filters: {
releasedAt: {
$null: true,
},
name,
},
})) as Release[];
const isNameUnique = pendingReleases.length === 0;
if (!isNameUnique) {
throw new errors.ValidationError(`Release with name ${name} already exists`);
}
},
});
export default createReleaseValidationService;

View File

@ -1,28 +1,14 @@
import { Attribute, Common } from '@strapi/types';
import { Attribute, Common, Schema } from '@strapi/types';
import type { Release, Pagination } from './releases';
import type { Entity } from '../types';
import type { errors } from '@strapi/utils';
type ReleaseActionEntry = Entity & {
export type ReleaseActionEntry = Entity & {
// Entity attributes
[key: string]: Attribute.Any;
} & {
locale?: string;
status: 'published' | 'draft';
};
type ReleaseActionEntryData = {
id: ReleaseActionEntry['id'];
locale?: {
name: string;
code: string;
};
contentType: {
mainFieldValue?: string;
displayName: string;
};
status: 'published' | 'draft';
};
export interface ReleaseAction extends Entity {
@ -33,6 +19,21 @@ export interface ReleaseAction extends Entity {
release: Release;
}
export interface FormattedReleaseAction extends Entity {
type: 'publish' | 'unpublish';
entry: ReleaseActionEntry;
contentType: {
uid: Common.UID.ContentType;
mainFieldValue?: string;
displayName: string;
};
locale?: {
name: string;
code: string;
};
release: Release;
}
/**
* POST /content-releases/:releaseId/actions - Create a release action
*/
@ -74,10 +75,12 @@ export declare namespace GetReleaseActions {
export interface Response {
data: {
[key: string]: Array<ReleaseAction & { entry: ReleaseActionEntryData }>;
[key: string]: Array<FormattedReleaseAction>;
};
meta: {
pagination: Pagination;
contentTypes: Record<Schema.ContentType['uid'], Schema.ContentType>;
components: Record<Schema.Component['uid'], Schema.Component>;
};
}
}

View File

@ -14,8 +14,7 @@ import { AttributeIcon, IconByType } from '../AttributeIcon';
import { OptionBoxWrapper } from './OptionBoxWrapper';
// TODO: remove blocks from array on 4.16 release (after blocks stable)
const newAttributes = ['blocks'];
const newAttributes: string[] = [];
const NewBadge = () => (
<Flex grow={1} justifyContent="flex-end">

View File

@ -81,7 +81,7 @@ describe('Local Strapi Destination Provider - Get Assets Stream', () => {
await provider.bootstrap();
expect(async () => provider.createAssetsWriteStream()).rejects.toThrow(
'Attempting to transfer assets when they are not included'
'Attempting to transfer assets when `assets` is not set in restore options'
);
});

View File

@ -282,7 +282,9 @@ class LocalStrapiDestinationProvider implements IDestinationProvider {
assertValidStrapi(this.strapi, 'Not able to stream Assets');
if (!this.#areAssetsIncluded()) {
throw new ProviderTransferError('Attempting to transfer assets when they are not included');
throw new ProviderTransferError(
'Attempting to transfer assets when `assets` is not set in restore options'
);
}
const removeAssetsBackup = this.#removeAssetsBackup.bind(this);

View File

@ -49,8 +49,10 @@ module.exports = ({ env }) => ({
baseUrl: env('CDN_URL'),
rootPath: env('CDN_ROOT_PATH'),
s3Options: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
},
region: env('AWS_REGION'),
params: {
ACL: env('AWS_ACL', 'public-read'),
@ -87,8 +89,10 @@ module.exports = ({ env }) => ({
config: {
provider: 'aws-s3',
providerOptions: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
credentials: {
accessKeyId: env('AWS_ACCESS_KEY_ID'),
secretAccessKey: env('AWS_ACCESS_SECRET'),
},
region: env('AWS_REGION'),
params: {
ACL: 'private', // <== set ACL to private
@ -119,8 +123,10 @@ module.exports = ({ env }) => ({
config: {
provider: 'aws-s3',
providerOptions: {
accessKeyId: env('SCALEWAY_ACCESS_KEY_ID'),
secretAccessKey: env('SCALEWAY_ACCESS_SECRET'),
credentials: {
accessKeyId: env('SCALEWAY_ACCESS_KEY_ID'),
secretAccessKey: env('SCALEWAY_ACCESS_SECRET'),
},
endpoint: env('SCALEWAY_ENDPOINT'), // e.g. "s3.fr-par.scw.cloud"
params: {
Bucket: env('SCALEWAY_BUCKET'),
@ -224,8 +230,10 @@ upload: {
config: {
provider: 'aws-s3',
providerOptions: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_ACCESS_SECRET,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_ACCESS_SECRET,
},
region: process.env.AWS_REGION,
baseUrl: `https://s3.${region}.amazonaws.com/${bucket}`, // This line sets the custom url format
params: {

View File

@ -548,19 +548,27 @@ describe('Attributes', () => {
expect(modifiers[0].kind).toBe(ts.SyntaxKind.TypeReference);
expect(modifiers[0].typeName.escapedText).toBe('Attribute.SetMinMax');
expect(modifiers[0].typeArguments).toHaveLength(1);
expect(modifiers[0].typeArguments[0].kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(modifiers[0].typeArguments[0].members).toHaveLength(1);
const [setMinMax] = modifiers;
const { typeArguments } = setMinMax;
expect(typeArguments).toBeDefined();
expect(typeArguments).toHaveLength(2);
const [definition, typeofMinMax] = typeArguments;
// Min
expect(modifiers[0].typeArguments[0].members[0].kind).toBe(
ts.SyntaxKind.PropertyDeclaration
);
expect(modifiers[0].typeArguments[0].members[0].name.escapedText).toBe('min');
expect(modifiers[0].typeArguments[0].members[0].type.kind).toBe(
ts.SyntaxKind.NumericLiteral
);
expect(modifiers[0].typeArguments[0].members[0].type.text).toBe('2');
expect(definition.kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(definition.members).toHaveLength(1);
const [min] = definition.members;
expect(min.kind).toBe(ts.SyntaxKind.PropertyDeclaration);
expect(min.name.escapedText).toBe('min');
expect(min.type.kind).toBe(ts.SyntaxKind.NumericLiteral);
expect(min.type.text).toBe('2');
// Check for number keyword on the second typeArgument
expect(typeofMinMax.kind).toBe(ts.SyntaxKind.NumberKeyword);
});
test('No Min, Max: 3', () => {
@ -572,19 +580,27 @@ describe('Attributes', () => {
expect(modifiers[0].kind).toBe(ts.SyntaxKind.TypeReference);
expect(modifiers[0].typeName.escapedText).toBe('Attribute.SetMinMax');
expect(modifiers[0].typeArguments).toHaveLength(1);
expect(modifiers[0].typeArguments[0].kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(modifiers[0].typeArguments[0].members).toHaveLength(1);
const [setMinMax] = modifiers;
const { typeArguments } = setMinMax;
// Min
expect(modifiers[0].typeArguments[0].members[0].kind).toBe(
ts.SyntaxKind.PropertyDeclaration
);
expect(modifiers[0].typeArguments[0].members[0].name.escapedText).toBe('max');
expect(modifiers[0].typeArguments[0].members[0].type.kind).toBe(
ts.SyntaxKind.NumericLiteral
);
expect(modifiers[0].typeArguments[0].members[0].type.text).toBe('3');
expect(typeArguments).toBeDefined();
expect(typeArguments).toHaveLength(2);
const [definition, typeofMinMax] = typeArguments;
// Max
expect(definition.kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(definition.members).toHaveLength(1);
const [max] = definition.members;
expect(max.kind).toBe(ts.SyntaxKind.PropertyDeclaration);
expect(max.name.escapedText).toBe('max');
expect(max.type.kind).toBe(ts.SyntaxKind.NumericLiteral);
expect(max.type.text).toBe('3');
// Check for number keyword on the second typeArgument
expect(typeofMinMax.kind).toBe(ts.SyntaxKind.NumberKeyword);
});
test('Min: 4, Max: 12', () => {
@ -596,28 +612,64 @@ describe('Attributes', () => {
expect(modifiers[0].kind).toBe(ts.SyntaxKind.TypeReference);
expect(modifiers[0].typeName.escapedText).toBe('Attribute.SetMinMax');
expect(modifiers[0].typeArguments).toHaveLength(1);
expect(modifiers[0].typeArguments[0].kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(modifiers[0].typeArguments[0].members).toHaveLength(2);
const [setMinMax] = modifiers;
const { typeArguments } = setMinMax;
// Min
expect(modifiers[0].typeArguments[0].members[0].kind).toBe(
ts.SyntaxKind.PropertyDeclaration
);
expect(modifiers[0].typeArguments[0].members[0].name.escapedText).toBe('min');
expect(modifiers[0].typeArguments[0].members[0].type.kind).toBe(
ts.SyntaxKind.NumericLiteral
);
expect(modifiers[0].typeArguments[0].members[0].type.text).toBe('4');
expect(typeArguments).toBeDefined();
expect(typeArguments).toHaveLength(2);
expect(modifiers[0].typeArguments[0].members[1].kind).toBe(
ts.SyntaxKind.PropertyDeclaration
);
expect(modifiers[0].typeArguments[0].members[1].name.escapedText).toBe('max');
expect(modifiers[0].typeArguments[0].members[1].type.kind).toBe(
ts.SyntaxKind.NumericLiteral
);
expect(modifiers[0].typeArguments[0].members[1].type.text).toBe('12');
const [definition, typeofMinMax] = typeArguments;
// Min/Max
expect(definition.kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(definition.members).toHaveLength(2);
const [min, max] = definition.members;
expect(min.kind).toBe(ts.SyntaxKind.PropertyDeclaration);
expect(min.name.escapedText).toBe('min');
expect(min.type.kind).toBe(ts.SyntaxKind.NumericLiteral);
expect(min.type.text).toBe('4');
expect(max.kind).toBe(ts.SyntaxKind.PropertyDeclaration);
expect(max.name.escapedText).toBe('max');
expect(max.type.kind).toBe(ts.SyntaxKind.NumericLiteral);
expect(max.type.text).toBe('12');
// Check for number keyword on the second typeArgument
expect(typeofMinMax.kind).toBe(ts.SyntaxKind.NumberKeyword);
});
test('Min: "1"', () => {
const attribute = { min: '1' };
const modifiers = getAttributeModifiers(attribute);
expect(modifiers).toHaveLength(1);
expect(modifiers[0].kind).toBe(ts.SyntaxKind.TypeReference);
expect(modifiers[0].typeName.escapedText).toBe('Attribute.SetMinMax');
const [setMinMax] = modifiers;
const { typeArguments } = setMinMax;
expect(typeArguments).toBeDefined();
expect(typeArguments).toHaveLength(2);
const [definition, typeofMinMax] = typeArguments;
// Min/Max
expect(definition.kind).toBe(ts.SyntaxKind.TypeLiteral);
expect(definition.members).toHaveLength(1);
const [min] = definition.members;
expect(min.kind).toBe(ts.SyntaxKind.PropertyDeclaration);
expect(min.name.escapedText).toBe('min');
expect(min.type.kind).toBe(ts.SyntaxKind.StringLiteral);
expect(min.type.text).toBe('1');
// Check for string keyword on the second typeArgument
expect(typeofMinMax.kind).toBe(ts.SyntaxKind.StringKeyword);
});
});

View File

@ -1,12 +1,14 @@
'use strict';
const { factory } = require('typescript');
const ts = require('typescript');
const _ = require('lodash/fp');
const { addImport } = require('../imports');
const { getTypeNode, toTypeLiteral, withAttributeNamespace, NAMESPACES } = require('./utils');
const mappers = require('./mappers');
const { factory } = ts;
/**
* Create the base type node for a given attribute
*
@ -100,14 +102,39 @@ const getAttributeModifiers = (attribute) => {
}
// Min / Max
// TODO: Always provide a second type argument for min/max (ie: resolve the attribute scalar type with a `GetAttributeType<${mappers[attribute][0]}>` (useful for biginter (string values)))
if (!_.isNil(attribute.min) || !_.isNil(attribute.max)) {
const minMaxProperties = _.pick(['min', 'max'], attribute);
const { min, max } = minMaxProperties;
const typeofMin = typeof min;
const typeofMax = typeof max;
// Throws error if min/max exist but have different types to prevent unexpected behavior
if (min !== undefined && max !== undefined && typeofMin !== typeofMax) {
throw new Error('typeof min/max values mismatch');
}
const typeofMinMax = (max && typeofMax) ?? (min && typeofMin);
let typeKeyword;
// Determines type keyword (string/number) based on min/max options, throws error for invalid types
switch (typeofMinMax) {
case 'string':
typeKeyword = ts.SyntaxKind.StringKeyword;
break;
case 'number':
typeKeyword = ts.SyntaxKind.NumberKeyword;
break;
default:
throw new Error(
`Invalid data type for min/max options. Must be string or number, but found ${typeofMinMax}`
);
}
modifiers.push(
factory.createTypeReferenceNode(
factory.createIdentifier(withAttributeNamespace('SetMinMax')),
[toTypeLiteral(minMaxProperties)]
[toTypeLiteral(minMaxProperties), factory.createKeywordTypeNode(typeKeyword)]
)
);
}

View File

@ -1,6 +1,13 @@
// @ts-check
const { devices } = require('@playwright/test');
const getEnvNum = (envVar, defaultValue) => {
if (envVar !== undefined && envVar !== null) {
return Number(envVar);
}
return defaultValue;
};
/**
* @typedef ConfigOptions
* @type {{ port: number; testDir: string; appDir: string }}
@ -12,6 +19,10 @@ const { devices } = require('@playwright/test');
*/
const createConfig = ({ port, testDir, appDir }) => ({
testDir,
/* default timeout for a jest test to 30s */
timeout: getEnvNum(process.env.PLAYWRIGHT_TIMEOUT, 30 * 1000),
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
@ -33,8 +44,9 @@ const createConfig = ({ port, testDir, appDir }) => ({
use: {
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: `http://127.0.0.1:${port}`,
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Default time each action such as `click()` can take to 20s */
actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 20 * 1000),
/* Collect trace when a test failed on the CI. See https://playwright.dev/docs/trace-viewer
Until https://github.com/strapi/strapi/issues/18196 is fixed we can't enable this locally,
@ -74,7 +86,8 @@ const createConfig = ({ port, testDir, appDir }) => ({
webServer: {
command: `cd ${appDir} && npm run develop`,
url: `http://127.0.0.1:${port}`,
timeout: 60 * 1000,
/* default Strapi server startup timeout to 160s */
timeout: getEnvNum(process.env.PLAYWRIGHT_WEBSERVER_TIMEOUT, 160 * 1000),
reuseExistingServer: true,
stdout: 'pipe',
},

View File

@ -9324,6 +9324,7 @@ __metadata:
resolution: "@strapi/content-releases@workspace:packages/core/content-releases"
dependencies:
"@reduxjs/toolkit": "npm:1.9.7"
"@strapi/admin": "npm:4.17.1"
"@strapi/admin-test-utils": "npm:4.17.1"
"@strapi/design-system": "npm:1.14.1"
"@strapi/helper-plugin": "npm:4.17.1"
@ -9350,6 +9351,7 @@ __metadata:
typescript: "npm:5.2.2"
yup: "npm:0.32.9"
peerDependencies:
"@strapi/admin": ^4.19.0
"@strapi/strapi": ^4.15.1
react: ^17.0.0 || ^18.0.0
react-dom: ^17.0.0 || ^18.0.0