From 9f2fd88ff535740e0c6b13e5077d17c6d75d7c0f Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 8 Feb 2024 10:37:42 +0100 Subject: [PATCH 1/8] docs(content-releases): add frontend docs (#19391) * docs(content-releases): add frontend docs * docs(content-releases): change some content based on review' comments * docs(content-releases): rewrite some parts and change file names * docs(content-releases): small fix --- .../content-manager/03-content-releases.mdx | 20 ++++++++++ .../content-releases/02-frontend/00-intro.md | 21 +++++++++++ .../02-frontend/01-releases-page.mdx | 18 +++++++++ .../02-frontend/02-release-details-page.mdx | 37 +++++++++++++++++++ .../02-frontend/_category_.json | 5 +++ 5 files changed, 101 insertions(+) create mode 100644 docs/docs/docs/01-core/content-manager/03-content-releases.mdx create mode 100644 docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md create mode 100644 docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx create mode 100644 docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx create mode 100644 docs/docs/docs/01-core/content-releases/02-frontend/_category_.json diff --git a/docs/docs/docs/01-core/content-manager/03-content-releases.mdx b/docs/docs/docs/01-core/content-manager/03-content-releases.mdx new file mode 100644 index 0000000000..2a0c0f15c9 --- /dev/null +++ b/docs/docs/docs/01-core/content-manager/03-content-releases.mdx @@ -0,0 +1,20 @@ +--- +title: Content Releases +description: Guide for content releases in the content-manager. +tags: + - content-manager + - content-releases +--- + +## Summary + +Content releases is an enterprise feature so you need a valid license. +Additionally, your user role must be granted specific Permissions, such as `plugin::content-releases.read` and `plugin::content-releases.create-action`. You can modify these permissions in the Settings section. The capability to add entries to a release within the content manager is available through the content-manager edit view. + +### Edit view + +If the feature is enabled with the correct license, and the user has the necessary permissions enabled, a 'Releases' section will appear on the right side of the Edit View. The 'Releases' section has an 'Add to release' button. Clicking this button opens a modal allowing you to assign the entry to a specific release and specify the desired action (Publish/Unpublish). + +In the event that no releases are present, users need to create a release first, which can be done on the [Content Releases](../content-releases/00-intro.md) page. + +If the user's permissions also includes the `plugin::content-releases.delete-action`, the user can access additional options by clicking the 'More' button (represented by three dots) in the releases sidebar. This will reveal the 'Remove from release' button, providing the ability to detach the entry from the selected release. diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md b/docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md new file mode 100644 index 0000000000..4ee8e68519 --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/00-intro.md @@ -0,0 +1,21 @@ +--- +title: Introduction +tags: + - content-releases + - tech design +--- + +## Summary + +There are two pages, ReleasesPage and ReleaseDetailsPage. To access these pages a user will need a valid Strapi license with the feature enabled and at lease `plugin::content-releases.read` permissions. + +Redux toolkit is used to manage content releases data (data retrieval, release creation and editing, and fetching release actions). `Formik` is used to create/edit a release and all input components are controlled components. + +### License limits + +Most licenses have feature-based usage limits configured through Chargebee. These limits are exposed to the frontend through [`useLicenseLimits`](/docs/core/admin/ee/hooks/use-license-limits). +If the license doesn't specify the number of maximum pending releases an hard-coded is used: max. 3 pending releases. + +### Endpoints + +For a list of all available endpoints please refer to the [detailed backend design documentation](/docs/core/content-releases/backend). diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx b/docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx new file mode 100644 index 0000000000..306e7a0c95 --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/01-releases-page.mdx @@ -0,0 +1,18 @@ +--- +title: Releases page +tags: + - content-releases + - tech design +--- + +### Overview + +The releases page provides a comprehensive display of all available content releases. Content is organized into two tabs: 'pending' (releases not yet published) and 'done' (releases already published). + +#### New Release Creation: + +If a user has the `plugin::content-releases.update` permissions, a 'New release' button will be visible in the header. Clicking the button opens a form requiring a name. The name of a pending release must be unique. + +#### License limits + +If the user reaches the limit of maximum pending releases defined on their license then the 'New release' button is disabled and a banner is displayed to inform the user. diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx b/docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx new file mode 100644 index 0000000000..f9688d3c8e --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/02-release-details-page.mdx @@ -0,0 +1,37 @@ +--- +title: Release details page +tags: + - content-releases + - tech design +--- + +### Overview: + +The release details page allows users with appropriate permissions to do the following: + +#### Group entries: + +Entries in a release can be be grouped by the following options: + +- Content-Types: Group all entries with the same content type together +- Locales: Group all entries with the same locale together. +- Actions: Group all entries with the same action (publish/unpublish) together. + +#### Edit a release: + +If the user has the `plugin::content-releases.update` permissions, a "three dot" button will appear in the header giving access to a menu with an "Edit" option. +The Edit button opens a modal, enabling users to modify the release name. To save the changes, the new name must be unique (only for pending releases), non-empty, and distinctly modified. + +#### Delete a release: + +If the user has the `plugin::content-releases.delete` permissions, a "three dot" button will appear in the header giving access to a menu with a "Delete" option. Selecting this option triggers a confirmation modal. + +#### View Entry Status + +Each entry within a release is assigned a status, indicating its readiness for actions such as Publish/Unpublish or any validation errors present. If the entry has validation errors, a user can use the entry's "three dots" menu to access the "Edit entry" link which navigates directly to the entry in the content manager. + +Within the CM edit view, users can resolve any identified errors. Upon completion, clicking the "Refresh" button on the release details page updates the status of each entry to reflect any changes made. + +#### Publish a release: + +To execute the release of a content release, users can simply click on the "Publish" button (shown if you have the "plugin::content-releases.publish" permission). If any of the entries exhibit validation errors, the "Publish" action triggers a notification to alert users of the errors. diff --git a/docs/docs/docs/01-core/content-releases/02-frontend/_category_.json b/docs/docs/docs/01-core/content-releases/02-frontend/_category_.json new file mode 100644 index 0000000000..050c32499c --- /dev/null +++ b/docs/docs/docs/01-core/content-releases/02-frontend/_category_.json @@ -0,0 +1,5 @@ +{ + "label": "Frontend", + "collapsible": true, + "collapsed": true +} From e36ac0d3870067d9684b55e2e63f97377c076817 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fernando=20Ch=C3=A1vez?= Date: Thu, 8 Feb 2024 12:10:21 +0100 Subject: [PATCH 2/8] feat(content-releases): sync all scheduled releases on bootstrap and cancel them on destroy (#19435) * feat(content-releases): sync all scheduled releases on bootstrap and cancel all of them on destroy * apply marc feedback * throw error when sync --- .../server/src/__tests__/index.test.ts | 50 +++++++++++++++++++ .../content-releases/server/src/bootstrap.ts | 13 +++++ .../content-releases/server/src/destroy.ts | 17 +++++++ .../core/content-releases/server/src/index.ts | 2 + .../src/services/__tests__/scheduling.test.ts | 50 +++++++++++++++++++ .../server/src/services/scheduling.ts | 26 ++++++++++ 6 files changed, 158 insertions(+) create mode 100644 packages/core/content-releases/server/src/destroy.ts diff --git a/packages/core/content-releases/server/src/__tests__/index.test.ts b/packages/core/content-releases/server/src/__tests__/index.test.ts index da2d914f8d..f59aac92c4 100644 --- a/packages/core/content-releases/server/src/__tests__/index.test.ts +++ b/packages/core/content-releases/server/src/__tests__/index.test.ts @@ -5,12 +5,19 @@ import { ACTIONS } from '../constants'; const { features } = require('@strapi/strapi/dist/utils/ee'); const { register } = require('../register'); +const { bootstrap } = require('../bootstrap'); +const { getService } = require('../utils'); jest.mock('@strapi/strapi/dist/utils/ee', () => ({ features: { isEnabled: jest.fn(), }, })); + +jest.mock('../utils', () => ({ + getService: jest.fn(), +})); + describe('register', () => { const strapi = { features: { @@ -55,3 +62,46 @@ describe('register', () => { expect(strapi.admin.services.permission.actionProvider.registerMany).not.toHaveBeenCalled(); }); }); + +describe('bootstrap', () => { + const mockSyncFromDatabase = jest.fn(); + + getService.mockReturnValue({ + syncFromDatabase: mockSyncFromDatabase, + }); + + const strapi = { + db: { + lifecycles: { + subscribe: jest.fn(), + }, + }, + features: { + future: { + isEnabled: jest.fn(), + }, + }, + log: { + error: jest.fn(), + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should sync scheduled jobs from the database if contentReleasesScheduling flag is enabled', async () => { + strapi.features.future.isEnabled.mockReturnValue(true); + features.isEnabled.mockReturnValue(true); + mockSyncFromDatabase.mockResolvedValue(new Map()); + await bootstrap({ strapi }); + expect(mockSyncFromDatabase).toHaveBeenCalled(); + }); + + it('should not sync scheduled jobs from the database if contentReleasesScheduling flag is disabled', async () => { + strapi.features.future.isEnabled.mockReturnValue(false); + features.isEnabled.mockReturnValue(true); + await bootstrap({ strapi }); + expect(mockSyncFromDatabase).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/content-releases/server/src/bootstrap.ts b/packages/core/content-releases/server/src/bootstrap.ts index b473c3f659..d3fd466e85 100644 --- a/packages/core/content-releases/server/src/bootstrap.ts +++ b/packages/core/content-releases/server/src/bootstrap.ts @@ -2,6 +2,7 @@ import type { LoadedStrapi, Entity as StrapiEntity } from '@strapi/types'; import { RELEASE_ACTION_MODEL_UID } from './constants'; +import { getService } from './utils'; const { features } = require('@strapi/strapi/dist/utils/ee'); @@ -57,5 +58,17 @@ export const bootstrap = async ({ strapi }: { strapi: LoadedStrapi }) => { } }, }); + + if (strapi.features.future.isEnabled('contentReleasesScheduling')) { + getService('scheduling', { strapi }) + .syncFromDatabase() + .catch((err: Error) => { + strapi.log.error( + 'Error while syncing scheduled jobs from the database in the content-releases plugin. This could lead to errors in the releases scheduling.' + ); + + throw err; + }); + } } }; diff --git a/packages/core/content-releases/server/src/destroy.ts b/packages/core/content-releases/server/src/destroy.ts new file mode 100644 index 0000000000..368624b00e --- /dev/null +++ b/packages/core/content-releases/server/src/destroy.ts @@ -0,0 +1,17 @@ +import { Job } from 'node-schedule'; +import { LoadedStrapi } from '@strapi/types'; + +import { Release } from '../../shared/contracts/releases'; +import { getService } from './utils'; + +export const destroy = async ({ strapi }: { strapi: LoadedStrapi }) => { + if (strapi.features.future.isEnabled('contentReleasesScheduling')) { + const scheduledJobs: Map = getService('scheduling', { + strapi, + }).getAll(); + + for (const [, job] of scheduledJobs) { + job.cancel(); + } + } +}; diff --git a/packages/core/content-releases/server/src/index.ts b/packages/core/content-releases/server/src/index.ts index 5bf8d9c36e..71e2a62075 100644 --- a/packages/core/content-releases/server/src/index.ts +++ b/packages/core/content-releases/server/src/index.ts @@ -1,6 +1,7 @@ /* eslint-disable @typescript-eslint/no-var-requires */ import { register } from './register'; import { bootstrap } from './bootstrap'; +import { destroy } from './destroy'; import { contentTypes } from './content-types'; import { services } from './services'; import { controllers } from './controllers'; @@ -13,6 +14,7 @@ const getPlugin = () => { return { register, bootstrap, + destroy, contentTypes, services, controllers, diff --git a/packages/core/content-releases/server/src/services/__tests__/scheduling.test.ts b/packages/core/content-releases/server/src/services/__tests__/scheduling.test.ts index 572e5333ed..050bc1fe17 100644 --- a/packages/core/content-releases/server/src/services/__tests__/scheduling.test.ts +++ b/packages/core/content-releases/server/src/services/__tests__/scheduling.test.ts @@ -114,4 +114,54 @@ describe('Scheduling service', () => { expect(scheduledJobs.size).toBe(0); }); }); + + describe('getAll', () => { + it('should return all the scheduled jobs', async () => { + const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() }); + // @ts-expect-error - scheduleJob is a mock + scheduleJob.mockImplementation(mockScheduleJob); + + const strapiMock = { + ...baseStrapiMock, + db: { + query: jest.fn(() => ({ + findOne: jest.fn().mockReturnValue({ id: 1 }), + })), + }, + }; + + const date = new Date(); + + // @ts-expect-error Ignore missing properties + const schedulingService = createSchedulingService({ strapi: strapiMock }); + await schedulingService.set('1', date); + expect(schedulingService.getAll().size).toBe(1); + }); + }); + + describe('syncFromDatabase', () => { + it('should sync the scheduled jobs from the database', async () => { + const mockScheduleJob = jest.fn().mockReturnValue({ cancel: jest.fn() }); + // @ts-expect-error - scheduleJob is a mock + scheduleJob.mockImplementation(mockScheduleJob); + + const strapiMock = { + ...baseStrapiMock, + db: { + query: jest.fn(() => ({ + findMany: jest + .fn() + .mockReturnValue([{ id: 1, scheduledAt: new Date(), releasedAt: null }]), + findOne: jest.fn().mockReturnValue({ id: 1 }), + })), + }, + }; + + // @ts-expect-error Ignore missing properties + const schedulingService = createSchedulingService({ strapi: strapiMock }); + const scheduledJobs = await schedulingService.syncFromDatabase(); + expect(scheduledJobs.size).toBe(1); + expect(mockScheduleJob).toHaveBeenCalledWith(expect.any(Date), expect.any(Function)); + }); + }); }); diff --git a/packages/core/content-releases/server/src/services/scheduling.ts b/packages/core/content-releases/server/src/services/scheduling.ts index 9784b1510d..b9ea997f72 100644 --- a/packages/core/content-releases/server/src/services/scheduling.ts +++ b/packages/core/content-releases/server/src/services/scheduling.ts @@ -47,6 +47,32 @@ const createSchedulingService = ({ strapi }: { strapi: LoadedStrapi }) => { return scheduledJobs; }, + + getAll() { + return scheduledJobs; + }, + + /** + * On bootstrap, we can use this function to make sure to sync the scheduled jobs from the database that are not yet released + * This is useful in case the server was restarted and the scheduled jobs were lost + * This also could be used to sync different Strapi instances in case of a cluster + */ + async syncFromDatabase() { + const releases = await strapi.db.query(RELEASE_MODEL_UID).findMany({ + where: { + scheduledAt: { + $gte: new Date(), + }, + releasedAt: null, + }, + }); + + for (const release of releases) { + this.set(release.id, release.scheduledAt); + } + + return scheduledJobs; + }, }; }; From 14e824252a07eab055f79b6fab711c396076eaa5 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 8 Feb 2024 14:01:40 +0100 Subject: [PATCH 3/8] fix(content-releases): replace Popover with Menu component in the Details page (#19408) * fix(content-releases): replace Popover with Menu to avoid weird behaviour onClick * fix(content-releases): replace MenuButton with Menu.Item --- .../release-details-page.spec.ts | 4 +- .../admin/src/pages/ReleaseDetailsPage.tsx | 156 ++++++++---------- .../pages/tests/ReleaseDetailsPage.test.tsx | 16 +- 3 files changed, 80 insertions(+), 96 deletions(-) diff --git a/e2e/tests/content-releases/release-details-page.spec.ts b/e2e/tests/content-releases/release-details-page.spec.ts index c42720ddbe..b707f009e8 100644 --- a/e2e/tests/content-releases/release-details-page.spec.ts +++ b/e2e/tests/content-releases/release-details-page.spec.ts @@ -73,7 +73,7 @@ describeOnCondition(edition === 'EE')('Release page', () => { test('A user should be able to edit and delete a release', async ({ page }) => { // Edit the release await page.getByRole('button', { name: 'Release edit and delete menu' }).click(); - await page.getByRole('button', { name: 'Edit' }).click(); + await page.getByRole('menuitem', { name: 'Edit' }).click(); await expect(page.getByRole('dialog', { name: 'Edit release' })).toBeVisible(); await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled(); await page.getByRole('textbox', { name: 'Name' }).fill('Trent Crimm: Independent'); @@ -84,7 +84,7 @@ describeOnCondition(edition === 'EE')('Release page', () => { // Delete the release await page.getByRole('button', { name: 'Release edit and delete menu' }).click(); - await page.getByRole('button', { name: 'Delete' }).click(); + await page.getByRole('menuitem', { name: 'Delete' }).click(); await page.getByRole('button', { name: 'Confirm' }).click(); // Wait for client side redirect to the releases page await page.waitForURL('/admin/plugins/content-releases'); diff --git a/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx b/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx index a49244be31..632ae31dbc 100644 --- a/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx +++ b/packages/core/content-releases/admin/src/pages/ReleaseDetailsPage.tsx @@ -9,7 +9,6 @@ import { IconButton, Link, Main, - Popover, Tr, Td, Typography, @@ -19,7 +18,7 @@ import { Icon, Tooltip, } from '@strapi/design-system'; -import { LinkButton } from '@strapi/design-system/v2'; +import { LinkButton, Menu } from '@strapi/design-system/v2'; import { CheckPermissions, LoadingIndicatorPage, @@ -76,10 +75,7 @@ const ReleaseInfoWrapper = styled(Flex)` border-top: 1px solid ${({ theme }) => theme.colors.neutral150}; `; -const StyledFlex = styled(Flex)<{ disabled?: boolean }>` - align-self: stretch; - cursor: ${({ disabled }) => (disabled ? 'not-allowed' : 'pointer')}; - +const StyledMenuItem = styled(Menu.Item)<{ disabled?: boolean }>` svg path { fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500}; } @@ -108,31 +104,6 @@ const TypographyMaxWidth = styled(Typography)` max-width: 300px; `; -interface PopoverButtonProps { - onClick?: (event: React.MouseEvent) => void; - disabled?: boolean; - children: React.ReactNode; -} - -const PopoverButton = ({ onClick, disabled, children }: PopoverButtonProps) => { - return ( - - {children} - - ); -}; - interface EntryValidationTextProps { action: ReleaseAction['type']; schema: Schema.ContentType; @@ -229,8 +200,6 @@ export const ReleaseDetailsLayout = ({ }: ReleaseDetailsLayoutProps) => { const { formatMessage } = useIntl(); const { releaseId } = useParams<{ releaseId: string }>(); - const [isPopoverVisible, setIsPopoverVisible] = React.useState(false); - const moreButtonRef = React.useRef(null!); const { data, isLoading: isLoadingDetails, @@ -248,15 +217,6 @@ export const ReleaseDetailsLayout = ({ const release = data?.data; - const handleTogglePopover = () => { - setIsPopoverVisible((prev) => !prev); - }; - - const openReleaseModal = () => { - toggleEditReleaseModal(); - handleTogglePopover(); - }; - const handlePublishRelease = async () => { const response = await publishRelease({ id: releaseId }); @@ -292,11 +252,6 @@ export const ReleaseDetailsLayout = ({ } }; - const openWarningConfirmDialog = () => { - toggleWarningSubmit(); - handleTogglePopover(); - }; - const handleRefresh = () => { dispatch(releaseApi.util.invalidateTags([{ type: 'ReleaseAction', id: 'LIST' }])); }; @@ -370,43 +325,72 @@ export const ReleaseDetailsLayout = ({ primaryAction={ !release.releasedAt && ( - - - - {isPopoverVisible && ( - - - - - - {formatMessage({ - id: 'content-releases.header.actions.edit', - defaultMessage: 'Edit', - })} - - - - - - {formatMessage({ - id: 'content-releases.header.actions.delete', - defaultMessage: 'Delete', - })} - - + + {/* + TODO Fix in the DS + - as={IconButton} has TS error: Property 'icon' does not exist on type 'IntrinsicAttributes & TriggerProps & RefAttributes' + - The Icon doesn't actually show unless you hack it with some padding...and it's still a little strange + */} + } + variant="tertiary" + /> + {/* + TODO: Using Menu instead of SimpleMenu mainly because there is no positioning provided from the DS, + Refactor this once fixed in the DS + */} + + + + + + + {formatMessage({ + id: 'content-releases.header.actions.edit', + defaultMessage: 'Edit', + })} + + + + + + + + {formatMessage({ + id: 'content-releases.header.actions.delete', + defaultMessage: 'Delete', + })} + + + - - )} + +