mirror of
https://github.com/strapi/strapi.git
synced 2025-12-27 15:13:21 +00:00
Merge branch 'develop' into v5/main
This commit is contained in:
commit
79aef88064
@ -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": [],
|
||||
},
|
||||
],
|
||||
[],
|
||||
|
||||
@ -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",
|
||||
|
||||
59
packages/core/admin/admin/src/hooks/useDocument.ts
Normal file
59
packages/core/admin/admin/src/hooks/useDocument.ts
Normal 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 };
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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']>({
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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`]};
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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: {},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
11
packages/core/content-releases/admin/src/store/hooks.ts
Normal file
11
packages/core/content-releases/admin/src/store/hooks.ts
Normal 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 };
|
||||
@ -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",
|
||||
|
||||
@ -22,9 +22,9 @@ describe('register', () => {
|
||||
addDestroyListenerCallback: jest.fn(),
|
||||
})),
|
||||
})),
|
||||
eventHub: {
|
||||
on: jest.fn(),
|
||||
},
|
||||
hook: jest.fn(() => ({
|
||||
register: jest.fn(),
|
||||
})),
|
||||
admin: {
|
||||
services: {
|
||||
permission: {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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' }]),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@ -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;
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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'
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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)]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
},
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user