feat: disabled dp with content releases (#19756)

* feat: disabled dp with content releases

* chore: export admin test setup (#19794)

* fix: export admin store config

* fix: pr feedback

* fix: move default config fallback

* chore: wip place everything on sub path

* chore: tweak tsconfig to sort build out

* chore: user admin server setup for content-releases

---------

Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>

* chore: fix tests with permissions

---------

Co-authored-by: markkaylor <mark.kaylor@strapi.io>
Co-authored-by: Josh <37798644+joshuaellis@users.noreply.github.com>
This commit is contained in:
Marc Roig 2024-03-19 12:36:48 +01:00 committed by GitHub
parent b17a180f13
commit 5004c4cc5a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 183 additions and 215 deletions

View File

@ -1884,7 +1884,7 @@ const CM_CONTENT_TYPE_MOCK_DATA = [
displayName: 'Article',
description: '',
},
options: {},
options: { draftAndPublish: true },
attributes: {
id: {
type: 'string',

View File

@ -1,6 +1,6 @@
import { errors } from '@strapi/utils';
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { type SetupServer, setupServer } from 'msw/node';
import * as qs from 'qs';
import { COLLECTION_TYPES, SINGLE_TYPES } from '../src/content-manager/constants/collections';
@ -8,7 +8,7 @@ import { historyHandlers } from '../src/content-manager/history/tests/server';
import { MockData, mockData } from './mockData';
export const server = setupServer(
export const server: SetupServer = setupServer(
...[
/**
* TRACKING

View File

@ -1,7 +1,7 @@
/* eslint-disable check-file/filename-naming-convention */
import * as React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { ConfigureStoreOptions, configureStore } from '@reduxjs/toolkit';
import { fixtures } from '@strapi/admin-test-utils';
import { darkTheme, lightTheme } from '@strapi/design-system';
import { Permission, RBACContext } from '@strapi/helper-plugin';
@ -51,9 +51,33 @@ setLogger({
interface ProvidersProps {
children: React.ReactNode;
initialEntries?: MemoryRouterProps['initialEntries'];
storeConfig?: Partial<ConfigureStoreOptions>;
permissions?: Permission[];
}
const Providers = ({ children, initialEntries }: ProvidersProps) => {
const defaultTestStoreConfig = {
preloadedState: initialState,
reducer: {
[adminApi.reducerPath]: adminApi.reducer,
admin_app: appReducer,
rbacProvider: RBACReducer,
'content-manager_app': cmAppReducer,
[contentManagerApi.reducerPath]: contentManagerApi.reducer,
'content-manager': contentManagerReducer,
},
// @ts-expect-error this fails.
middleware: (getDefaultMiddleware) => [
...getDefaultMiddleware({
// Disable timing checks for test env
immutableCheck: false,
serializableCheck: false,
}),
adminApi.middleware,
contentManagerApi.middleware,
],
};
const Providers = ({ children, initialEntries, storeConfig, permissions = [] }: ProvidersProps) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@ -62,28 +86,10 @@ const Providers = ({ children, initialEntries }: ProvidersProps) => {
},
});
const store = configureStore({
const store = configureStore(
// @ts-expect-error we've not filled up the entire initial state.
preloadedState: initialState,
reducer: {
[adminApi.reducerPath]: adminApi.reducer,
admin_app: appReducer,
rbacProvider: RBACReducer,
'content-manager_app': cmAppReducer,
[contentManagerApi.reducerPath]: contentManagerApi.reducer,
'content-manager': contentManagerReducer,
},
// @ts-expect-error this fails.
middleware: (getDefaultMiddleware) => [
...getDefaultMiddleware({
// Disable timing checks for test env
immutableCheck: false,
serializableCheck: false,
}),
adminApi.middleware,
contentManagerApi.middleware,
],
});
storeConfig ?? defaultTestStoreConfig
);
const router = createMemoryRouter(
[
@ -143,6 +149,7 @@ const Providers = ({ children, initialEntries }: ProvidersProps) => {
refetchPermissions: jest.fn(),
allPermissions: [
...fixtures.permissions.allPermissions,
...permissions,
{
id: 314,
action: 'admin::users.read',
@ -205,18 +212,24 @@ export interface RenderOptions {
renderOptions?: RTLRenderOptions;
userEventOptions?: Parameters<typeof userEvent.setup>[0];
initialEntries?: MemoryRouterProps['initialEntries'];
providerOptions?: Pick<ProvidersProps, 'storeConfig' | 'permissions'>;
}
/**
* @alpha
* @description A custom render function that wraps the component with the necessary providers,
* for use of testing components within the Strapi Admin.
*/
const render = (
ui: React.ReactElement,
{ renderOptions, userEventOptions, initialEntries }: RenderOptions = {}
{ renderOptions, userEventOptions, initialEntries, providerOptions }: RenderOptions = {}
): RenderResult & { user: ReturnType<typeof userEvent.setup> } => {
const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = renderOptions ?? {};
return {
...renderRTL(ui, {
wrapper: ({ children }) => (
<Providers initialEntries={initialEntries}>
<Providers initialEntries={initialEntries} {...providerOptions}>
<Wrapper>{children}</Wrapper>
</Providers>
),
@ -226,6 +239,11 @@ const render = (
};
};
/**
* @alpha
* @description A custom render-hook function that wraps the component with the necessary providers,
* for use of testing hooks within the Strapi Admin.
*/
const renderHook = <
Result,
Props,
@ -249,4 +267,4 @@ const renderHook = <
});
};
export { render, renderHook, waitFor, server, act, screen, fireEvent };
export { render, renderHook, waitFor, server, act, screen, fireEvent, defaultTestStoreConfig };

View File

@ -11,12 +11,12 @@
"../shared",
"./module.d.ts",
"../ee/admin",
"../package.json"
"../package.json",
"./tests"
],
"exclude": [
"./tests",
"**/__mocks__",
"**/tests",
"./src/**/tests",
"**/*.test.*",
"../ee/admin/**/*.test.*",
"../ee/admin/**/__mocks__"

View File

@ -27,6 +27,13 @@
"require": "./dist/admin/index.js",
"default": "./dist/admin/index.js"
},
"./strapi-admin/tests": {
"types": "./dist/admin/tests/utils.d.ts",
"source": "./admin/tests/utils.tsx",
"import": "./dist/admin/tests/index.mjs",
"require": "./dist/admin/tests/index.js",
"default": "./dist/admin/tests/index.js"
},
"./_internal": {
"types": "./dist/_internal/index.d.ts",
"source": "./_internal/index.ts",

View File

@ -10,6 +10,14 @@ const config: Config = defineConfig({
tsconfig: './admin/tsconfig.build.json',
runtime: 'web',
},
{
source: './admin/tests/utils.tsx',
import: './dist/admin/tests/index.mjs',
require: './dist/admin/tests/index.js',
types: './dist/admin/tests/utils.d.ts',
tsconfig: './admin/tsconfig.build.json',
runtime: 'web',
},
{
source: './_internal/index.ts',
import: './dist/_internal.mjs',

View File

@ -1,7 +1,12 @@
import * as React from 'react';
import { skipToken } from '@reduxjs/toolkit/query';
import { useAPIErrorHandler, useNotification, useQueryParams } from '@strapi/admin/strapi-admin';
import {
useAPIErrorHandler,
useNotification,
useQueryParams,
unstable_useDocument,
} from '@strapi/admin/strapi-admin';
import {
Box,
Button,
@ -248,7 +253,7 @@ const AddActionToReleaseModal = ({
export const CMReleasesContainer = () => {
const [isModalOpen, setIsModalOpen] = React.useState(false);
const { formatMessage, formatDate, formatTime } = useIntl();
const { id, slug } = useParams<{
const { id, slug, collectionType } = useParams<{
id: string;
origin: string;
slug: string;
@ -259,6 +264,13 @@ export const CMReleasesContainer = () => {
allowedActions: { canCreateAction, canMain, canDeleteAction },
} = useRBAC(PERMISSIONS);
const { schema } = unstable_useDocument({
collectionType: collectionType!,
model: slug!,
});
const hasDraftAndPublish = schema?.options?.draftAndPublish;
const contentTypeUid = slug as Common.UID.ContentType;
const canFetch = id != null && contentTypeUid != null;
const fetchParams = canFetch
@ -282,8 +294,9 @@ export const CMReleasesContainer = () => {
/**
* - Impossible to add entry to release before it exists
* - Content types without draft and publish cannot add entries to release
*/
if (isCreatingEntry) {
if (isCreatingEntry || !hasDraftAndPublish) {
return null;
}

View File

@ -43,21 +43,22 @@ describe('CMReleasesContainer', () => {
it('should render the container', async () => {
render();
await screen.findByRole('complementary', { name: 'Releases' });
await screen.findByRole('button', { name: 'Add to release' });
const informationBox = await screen.findByRole('complementary', { name: 'Releases' });
expect(informationBox).toBeInTheDocument();
const addToReleaseButton = await screen.findByRole('button', { name: 'Add to release' });
expect(addToReleaseButton).toBeInTheDocument();
});
it('should open and close the add to release modal', async () => {
const { user } = render();
await screen.findByRole('complementary', { name: 'Releases' });
const addToReleaseButton = screen.getByRole('button', { name: 'Add to release' });
const addToReleaseButton = await screen.findByRole('button', { name: 'Add to release' });
await user.click(addToReleaseButton);
const modalDialog = screen.getByRole('dialog', { name: 'Add to release' });
const modalDialog = await screen.findByRole('dialog', { name: 'Add to release' });
expect(modalDialog).toBeVisible();
const closeButton = screen.getByRole('button', { name: 'Close the modal' });
const closeButton = await screen.findByRole('button', { name: 'Close the modal' });
await user.click(closeButton);
expect(modalDialog).not.toBeVisible();
});
@ -76,17 +77,15 @@ describe('CMReleasesContainer', () => {
const { user } = render();
await screen.findByRole('complementary', { name: 'Releases' });
const addToReleaseButton = screen.getByRole('button', { name: 'Add to release' });
const addToReleaseButton = await screen.findByRole('button', { name: 'Add to release' });
await user.click(addToReleaseButton);
// Select a value received from the server
const select = screen.getByRole('combobox', { name: 'Select a release' });
const select = await screen.findByRole('combobox', { name: 'Select a release' });
await user.click(select);
await user.click(screen.getByRole('option', { name: 'release1' }));
await user.click(await screen.findByRole('option', { name: 'release1' }));
const submitButtom = screen.getByRole('button', { name: 'Continue' });
const submitButtom = await screen.findByRole('button', { name: 'Continue' });
expect(submitButtom).toBeEnabled();
});

View File

@ -9,7 +9,7 @@ describe('ReleaseActionMenu', () => {
<ReleaseActionMenu.DeleteReleaseActionItem releaseId="1" actionId="1" />
<ReleaseActionMenu.ReleaseActionEntryLinkItem
contentTypeUid="api::category.category"
locale="en"
locale="en-GB"
entryId={1}
/>
</ReleaseActionMenu.Root>

View File

@ -1,4 +0,0 @@
// import { rest } from 'msw';
import { setupServer } from 'msw/node';
export const server = setupServer(...[]);

View File

@ -1,4 +1,4 @@
import { server } from './server';
import { server } from '@strapi/admin/strapi-admin/tests';
beforeAll(() => {
server.listen();

View File

@ -1,38 +0,0 @@
import { fixtures } from '@strapi/admin-test-utils';
import { PERMISSIONS } from '../src/constants';
/**
* This is for the redux store in `utils`.
* The more we adopt it, the bigger it will get which is okay.
*/
const initialState = {
admin_app: { permissions: fixtures.permissions.app },
rbacProvider: {
allPermissions: [
...fixtures.permissions.allPermissions,
{
id: 314,
action: 'admin::users.read',
subject: null,
properties: {},
conditions: [],
},
...Object.values(PERMISSIONS).flat(),
],
collectionTypesRelatedPermissions: {
'api::category.category': {
'plugin::content-manager.explorer.update': [
{
action: 'plugin::content-manager.explorer.update',
subject: 'api::category.category',
properties: {
locales: ['en'],
},
},
],
},
},
},
};
export { initialState };

View File

@ -1,135 +1,39 @@
/* eslint-disable check-file/filename-naming-convention */
import * as React from 'react';
import { configureStore } from '@reduxjs/toolkit';
import { NotificationsProvider } from '@strapi/admin/strapi-admin';
import { fixtures } from '@strapi/admin-test-utils';
import { DesignSystemProvider } from '@strapi/design-system';
import { Permission, RBACContext } from '@strapi/helper-plugin';
import { ConfigureStoreOptions } from '@reduxjs/toolkit';
import {
renderHook as renderHookRTL,
render as renderRTL,
defaultTestStoreConfig,
render as renderAdmin,
RenderOptions,
server,
waitFor,
RenderOptions as RTLRenderOptions,
RenderResult,
act,
screen,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { IntlProvider } from 'react-intl';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter, MemoryRouterProps } from 'react-router-dom';
} from '@strapi/admin/strapi-admin/tests';
import { PERMISSIONS } from '../src/constants';
import { releaseApi } from '../src/services/release';
import { server } from './server';
import { initialState } from './store';
interface ProvidersProps {
children: React.ReactNode;
initialEntries?: MemoryRouterProps['initialEntries'];
}
const Providers = ({ children, initialEntries }: ProvidersProps) => {
const store = configureStore({
preloadedState: initialState,
reducer: {
[releaseApi.reducerPath]: releaseApi.reducer,
admin_app: (state = initialState) => state,
rbacProvider: (state = initialState) => state,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
immutableCheck: false,
serializableCheck: false,
}).concat(releaseApi.middleware),
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// en is the default locale of the admin app.
return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter initialEntries={initialEntries}>
<DesignSystemProvider locale="en">
<IntlProvider locale="en" messages={{}} textComponent="span">
<NotificationsProvider>
<RBACContext.Provider
value={{
refetchPermissions: jest.fn(),
allPermissions: [
...fixtures.permissions.allPermissions,
{
id: 314,
action: 'admin::users.read',
subject: null,
properties: {},
conditions: [],
actionParameters: {},
},
...Object.values(PERMISSIONS).flat(),
] as Permission[],
}}
>
{children}
</RBACContext.Provider>
</NotificationsProvider>
</IntlProvider>
</DesignSystemProvider>
</MemoryRouter>
</Provider>
</QueryClientProvider>
);
const storeConfig: ConfigureStoreOptions = {
preloadedState: defaultTestStoreConfig.preloadedState,
reducer: {
...defaultTestStoreConfig.reducer,
[releaseApi.reducerPath]: releaseApi.reducer,
},
middleware: (getDefaultMiddleware) => [
...defaultTestStoreConfig.middleware(getDefaultMiddleware),
releaseApi.middleware,
],
};
// eslint-disable-next-line react/jsx-no-useless-fragment
const fallbackWrapper = ({ children }: { children: React.ReactNode }) => <>{children}</>;
export interface RenderOptions {
renderOptions?: RTLRenderOptions;
userEventOptions?: Parameters<typeof userEvent.setup>[0];
initialEntries?: MemoryRouterProps['initialEntries'];
}
const render = (
ui: React.ReactElement,
{ renderOptions, userEventOptions, initialEntries }: RenderOptions = {}
): RenderResult & { user: ReturnType<typeof userEvent.setup> } => {
const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = renderOptions ?? {};
return {
...renderRTL(ui, {
wrapper: ({ children }) => (
<Providers initialEntries={initialEntries}>
<Wrapper>{children}</Wrapper>
</Providers>
),
...restOptions,
}),
user: userEvent.setup(userEventOptions),
};
};
const renderHook: typeof renderHookRTL = (hook, options) => {
const { wrapper: Wrapper = fallbackWrapper, ...restOptions } = options ?? {};
return renderHookRTL(hook, {
wrapper: ({ children }) => (
<Providers>
<Wrapper>{children}</Wrapper>
</Providers>
),
...restOptions,
options: RenderOptions = {}
): ReturnType<typeof renderAdmin> =>
renderAdmin(ui, {
...options,
providerOptions: { storeConfig, permissions: Object.values(PERMISSIONS).flat() },
});
};
export { render, renderHook, waitFor, act, screen, server };
export { render, waitFor, act, screen, server };

View File

@ -6,7 +6,9 @@ import { getEntryValidStatus, getService } from './utils';
export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
if (strapi.ee.features.isEnabled('cms-content-releases')) {
const contentTypesWithDraftAndPublish = Object.keys(strapi.contentTypes);
const contentTypesWithDraftAndPublish = Object.keys(strapi.contentTypes).filter(
(uid: any) => strapi.contentTypes[uid]?.options?.draftAndPublish
);
// Clean up release-actions when an entry is deleted
strapi.db.lifecycles.subscribe({
@ -53,7 +55,7 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => {
async beforeDeleteMany(event) {
const { model, params } = event;
// @ts-expect-error TODO: lifecycles types looks like are not 100% finished
if (model.kind === 'collectionType') {
if (model.kind === 'collectionType' && model.options?.draftAndPublish) {
const { where } = params;
const entriesToDelete = await strapi.db
.query(model.uid)

View File

@ -1,5 +1,5 @@
import type { Schema, Common } from '@strapi/types';
import { async } from '@strapi/utils';
import { contentTypes as contentTypesUtils, async } from '@strapi/utils';
import isEqual from 'lodash/isEqual';
import { difference, keys } from 'lodash';
@ -13,6 +13,35 @@ interface Input {
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)) ?? [];
@ -96,7 +125,9 @@ export async function migrateIsValidAndStatusReleases() {
export async function revalidateChangedContentTypes({ oldContentTypes, contentTypes }: Input) {
if (oldContentTypes !== undefined && contentTypes !== undefined) {
const contentTypesWithDraftAndPublish = Object.keys(oldContentTypes);
const contentTypesWithDraftAndPublish = Object.keys(oldContentTypes).filter(
(uid) => oldContentTypes[uid]?.options?.draftAndPublish
);
const releasesAffected = new Set();
async

View File

@ -4,6 +4,7 @@ import type { LoadedStrapi } from '@strapi/types';
import { ACTIONS, RELEASE_MODEL_UID, RELEASE_ACTION_MODEL_UID } from './constants';
import {
deleteActionsOnDeleteContentType,
deleteActionsOnDisableDraftAndPublish,
migrateIsValidAndStatusReleases,
revalidateChangedContentTypes,
disableContentTypeLocalized,
@ -14,7 +15,11 @@ export const register = async ({ strapi }: { strapi: LoadedStrapi }) => {
if (strapi.ee.features.isEnabled('cms-content-releases')) {
await strapi.admin.services.permission.actionProvider.registerMany(ACTIONS);
strapi.hook('strapi::content-types.beforeSync').register(disableContentTypeLocalized);
strapi
.hook('strapi::content-types.beforeSync')
.register(disableContentTypeLocalized)
.register(deleteActionsOnDisableDraftAndPublish);
strapi
.hook('strapi::content-types.afterSync')
.register(deleteActionsOnDeleteContentType)

View File

@ -25,6 +25,23 @@ describe('Release Validation service', () => {
'No content type found for uid api::plop.plop'
);
});
it('throws an error if the content type does not have draftAndPublish enabled', () => {
const strapiMock = {
...baseStrapiMock,
contentType: jest.fn().mockReturnValue({
options: {},
}),
};
// @ts-expect-error Ignore missing properties
const releaseValidationService = createReleaseValidationService({ strapi: strapiMock });
expect(() =>
releaseValidationService.validateEntryContentType('api::category.category')
).toThrow(
'Content type with uid api::category.category does not have draftAndPublish enabled'
);
});
});
describe('validateUniqueEntry', () => {

View File

@ -51,6 +51,12 @@ const createReleaseValidationService = ({ strapi }: { strapi: LoadedStrapi }) =>
if (!contentType) {
throw new errors.NotFoundError(`No content type found for uid ${contentTypeUid}`);
}
if (!contentType.options?.draftAndPublish) {
throw new errors.ValidationError(
`Content type with uid ${contentTypeUid} does not have draftAndPublish enabled`
);
}
},
async validatePendingReleasesLimit() {
// Use the maximum releases option if it exists, otherwise default to 3