Merge branch 'develop' into v5/main

This commit is contained in:
Josh 2024-03-04 15:22:52 +00:00
commit 3943ccbac6
25 changed files with 414 additions and 105 deletions

View File

@ -189,7 +189,7 @@ jobs:
if: failure() if: failure()
with: with:
name: ce-playwright-trace name: ce-playwright-trace
path: test-apps/e2e/**/test-results/**/trace.zip path: test-apps/e2e/test-results/**/trace.zip
retention-days: 1 retention-days: 1
e2e_ee: e2e_ee:
@ -231,7 +231,7 @@ jobs:
if: failure() if: failure()
with: with:
name: ee-playwright-trace name: ee-playwright-trace
path: test-apps/e2e/**/test-results/**/trace.zip path: test-apps/e2e/test-results/**/trace.zip
retention-days: 1 retention-days: 1
cli: cli:

View File

@ -29,6 +29,46 @@ This will spawn by default a Strapi instance per testing domain (e.g. content-ma
If you need to clean the test-apps folder because they are not working as expected, you can run `yarn test:e2e clean` which will clean said directory. If you need to clean the test-apps folder because they are not working as expected, you can run `yarn test:e2e clean` which will clean said directory.
### Running specific tests
To run only one domain, meaning a top-level directory in e2e/tests such as "admin" or "content-manager", use the `--domains` option.
```shell
yarn test:e2e --domains admin
yarn test:e2e --domain admin
```
To run a specific file, you can pass arguments and options to playwright using `--` between the test:e2e options and the playwright options, such as:
```shell
# to run just the login.spec.ts file in the admin domain
yarn test:e2e --domains admin -- login.spec.ts
```
### Concurrency / parallellization
By default, every domain is run with its own test app in parallel with the other domains. The tests within a domain are run in series, one at a time.
If you need an easier way to view the output, or have problems running multiple apps at once on your system, you can use the `-c` option
```shell
# only run one domain at a time
yarn test:e2e -c 1
```
### Env Variables to Control Test Config
Some helpers have been added to allow you to modify the playwright configuration on your own system without touching the playwright config file used by the test runner.
| env var | Description | Default |
| ---------------------------- | -------------------------------------------- | ------------------ |
| PLAYWRIGHT_WEBSERVER_TIMEOUT | timeout for starting the Strapi server | 16000 (160s) |
| PLAYWRIGHT_ACTION_TIMEOUT | playwright action timeout (ie, click()) | 15000 (15s) |
| PLAYWRIGHT_EXPECT_TIMEOUT | playwright expect waitFor timeout | 10000 (10s) |
| PLAYWRIGHT_TIMEOUT | playwright timeout, for each individual test | 30000 (30s) |
| PLAYWRIGHT_OUTPUT_DIR | playwright output dir, such as trace files | '../test-results/' |
| PLAYWRIGHT_VIDEO | set 'true' to save videos on failed tests | false |
## Strapi Templates ## Strapi Templates
The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) found at `e2e/app-template` in this folder we can store our premade content schemas & any customisations we may need such as other plugins / custom fields / endpoints etc. The test-app you create uses a [template](https://docs.strapi.io/developer-docs/latest/setup-deployment-guides/installation/templates.html) found at `e2e/app-template` in this folder we can store our premade content schemas & any customisations we may need such as other plugins / custom fields / endpoints etc.

3
e2e/README.md Normal file
View File

@ -0,0 +1,3 @@
## End-to-end Playwright Tests
See contributor docs in docs/docs/guides/e2e for more info

View File

@ -1,3 +1,5 @@
const { createTestTransferToken } = require('../../../create-transfer-token');
module.exports = { module.exports = {
rateLimitEnable(ctx) { rateLimitEnable(ctx) {
const { value } = ctx.request.body; const { value } = ctx.request.body;
@ -13,6 +15,11 @@ module.exports = {
await permissionService.cleanPermissionsInDatabase(); await permissionService.cleanPermissionsInDatabase();
ctx.send(200);
},
async resetTransferToken(ctx) {
await createTestTransferToken(strapi);
ctx.send(200); ctx.send(200);
}, },
}; };

View File

@ -16,5 +16,13 @@ module.exports = {
auth: false, auth: false,
}, },
}, },
{
method: 'POST',
path: '/config/resettransfertoken',
handler: 'config.resetTransferToken',
config: {
auth: false,
},
},
], ],
}; };

View File

@ -0,0 +1,27 @@
const { CUSTOM_TRANSFER_TOKEN_ACCESS_KEY } = require('./constants');
/**
* Make sure the test transfer token exists in the database
* @param {Strapi.Strapi} strapi
* @returns {Promise<void>}
*/
const createTestTransferToken = async (strapi) => {
const { token: transferTokenService } = strapi.admin.services.transfer;
const accessKeyHash = transferTokenService.hash(CUSTOM_TRANSFER_TOKEN_ACCESS_KEY);
const exists = await transferTokenService.exists({ accessKey: accessKeyHash });
if (!exists) {
await transferTokenService.create({
name: 'TestToken',
description: 'Transfer token used to seed the e2e database',
lifespan: null,
permissions: ['push'],
accessKey: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY,
});
}
};
module.exports = {
createTestTransferToken,
};

View File

@ -1,4 +1,4 @@
const { CUSTOM_TRANSFER_TOKEN_ACCESS_KEY } = require('./constants'); const { createTestTransferToken } = require('./create-transfer-token');
module.exports = { module.exports = {
/** /**
@ -23,25 +23,3 @@ module.exports = {
await createTestTransferToken(strapi); await createTestTransferToken(strapi);
}, },
}; };
/**
* Make sure the test transfer token exists in the database
* @param {Strapi.Strapi} strapi
* @returns {Promise<void>}
*/
const createTestTransferToken = async (strapi) => {
const { token: transferTokenService } = strapi.admin.services.transfer;
const accessKeyHash = transferTokenService.hash(CUSTOM_TRANSFER_TOKEN_ACCESS_KEY);
const exists = await transferTokenService.exists({ accessKey: accessKeyHash });
if (!exists) {
await transferTokenService.create({
name: 'TestToken',
description: 'Transfer token used to seed the e2e database',
lifespan: null,
permissions: ['push'],
accessKey: CUSTOM_TRANSFER_TOKEN_ACCESS_KEY,
});
}
};

View File

@ -5,6 +5,8 @@ const ALLOWED_CONTENT_TYPES = [
'admin::user', 'admin::user',
'admin::role', 'admin::role',
'admin::permission', 'admin::permission',
'admin::api-token',
'admin::transfer-token',
'api::article.article', 'api::article.article',
'api::author.author', 'api::author.author',
'api::homepage.homepage', 'api::homepage.homepage',

View File

@ -53,6 +53,19 @@ export const resetDatabaseAndImportDataFromPath = async (
engine.diagnostics.onDiagnostic(console.log); engine.diagnostics.onDiagnostic(console.log);
try {
// reset the transfer token to allow the transfer if it's been wiped (that is, not included in previous import data)
const res = await fetch(
`http://127.0.0.1:${process.env.PORT ?? 1337}/api/config/resettransfertoken`,
{
method: 'POST',
}
);
} catch (err) {
console.error('Token reset failed.' + JSON.stringify(err, null, 2));
process.exit(1);
}
try { try {
await engine.transfer(); await engine.transfer();
} catch { } catch {

View File

@ -0,0 +1,65 @@
import { test, expect } from '@playwright/test';
import { login } from '../../../utils/login';
import { resetDatabaseAndImportDataFromPath } from '../../../scripts/dts-import';
import { navToHeader, delay } from '../../../utils/shared';
const createTransferToken = async (page, tokenName, duration, type) => {
await navToHeader(
page,
['Settings', 'Transfer Tokens', 'Create new Transfer Token'],
'Create Transfer Token'
);
await page.getByLabel('Name*').click();
await page.getByLabel('Name*').fill(tokenName);
await page.getByLabel('Token duration').click();
await page.getByRole('option', { name: duration }).click();
await page.getByLabel('Token type').click();
await page.getByRole('option', { name: type }).click();
await page.getByRole('button', { name: 'Save' }).click();
await expect(page.getByText(/copy this token/)).toBeVisible();
await expect(page.getByText('Expiration date:')).toBeVisible();
};
test.describe('Transfer Tokens', () => {
test.beforeEach(async ({ page }) => {
await resetDatabaseAndImportDataFromPath('./e2e/data/with-admin.tar');
await page.goto('/admin');
await login({ page });
});
// Test token creation
const testCases = [
['30-day push token', '30 days', 'Push'],
['30-day pull token', '30 days', 'Pull'],
['30-day full-access token', '30 days', 'Full access'],
// if push+pull work generally that's good enough for e2e
['7-day token', '7 days', 'Full access'],
['90-day token', '90 days', 'Full access'],
['unlimited token', 'Unlimited', 'Full access'],
];
for (const [name, duration, type] of testCases) {
test(`A user should be able to create a ${name}`, async ({ page }) => {
await createTransferToken(page, name, duration, type);
});
}
test('Created tokens list page should be correct', async ({ page }) => {
await createTransferToken(page, 'my test token', 'unlimited', 'Full access');
// if we don't wait until createdAt is at least 1s, we see "NaN" for the timestamp
// TODO: fix the bug and remove this
await page.waitForTimeout(1100);
await navToHeader(page, ['Settings', 'Transfer Tokens'], 'Transfer Tokens');
const row = page.getByRole('gridcell', { name: 'my test token', exact: true });
await expect(row).toBeVisible();
await expect(page.getByText(/\d+ (second|minute)s? ago/)).toBeVisible();
// TODO: expand on this test, it could check edit and delete icons
});
});

View File

@ -70,7 +70,17 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
name: 'Date', name: 'Date',
}) })
.click(); .click();
await page.getByRole('gridcell', { name: 'Sunday, March 3, 2024' }).click();
const date = new Date();
date.setDate(date.getDate() + 1);
const formattedDate = date.toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
await page.getByRole('gridcell', { name: formattedDate }).click();
await page await page
.getByRole('combobox', { .getByRole('combobox', {

View File

@ -1,7 +1,25 @@
import { test } from '@playwright/test'; import { test, Page, expect } from '@playwright/test';
/** /**
* Execute a test suite only if the condition is true * Execute a test suite only if the condition is true
*/ */
export const describeOnCondition = (shouldDescribe: boolean) => export const describeOnCondition = (shouldDescribe: boolean) =>
shouldDescribe ? test.describe : test.describe.skip; shouldDescribe ? test.describe : test.describe.skip;
/**
* Navigate to a page and confirm the header, awaiting each step
*/
export const navToHeader = async (page: Page, navItems: string[], headerText: string) => {
for (const navItem of navItems) {
// This does not use getByRole because sometimes "Settings" is "Settings 1" if there's a badge notification
// BUT if we don't match exact it conflicts with "Advanceed Settings"
// As a workaround, we implement our own startsWith with page.locator
const item = page.locator(`role=link[name^="${navItem}"]`);
await expect(item).toBeVisible();
await item.click();
}
const header = page.getByRole('heading', { name: headerText, exact: true });
await expect(header).toBeVisible();
return header;
};

View File

@ -200,8 +200,8 @@ export const EditView = () => {
if (isCreating) { if (isCreating) {
const res = await createToken({ const res = await createToken({
...body, ...body,
// in case a token has a lifespan of "unlimited" the API only accepts zero as a number // lifespan must be "null" for unlimited (0 would mean instantly expired and isn't accepted)
lifespan: body.lifespan === '0' ? parseInt(body.lifespan) : null, lifespan: body?.lifespan || null,
permissions: body.type === 'custom' ? state.selectedActions : null, permissions: body.type === 'custom' ? state.selectedActions : null,
}); });
@ -340,7 +340,7 @@ export const EditView = () => {
name: apiToken?.name || '', name: apiToken?.name || '',
description: apiToken?.description || '', description: apiToken?.description || '',
type: apiToken?.type, type: apiToken?.type,
lifespan: apiToken?.lifespan ? apiToken.lifespan.toString() : apiToken?.lifespan, lifespan: apiToken?.lifespan,
}} }}
enableReinitialize enableReinitialize
onSubmit={(body, actions) => handleSubmit(body, actions)} onSubmit={(body, actions) => handleSubmit(body, actions)}

View File

@ -149,6 +149,8 @@ const EditView = () => {
if (isCreating) { if (isCreating) {
const res = await createToken({ const res = await createToken({
...body, ...body,
// lifespan must be "null" for unlimited (0 would mean instantly expired and isn't accepted)
lifespan: body?.lifespan || null,
permissions, permissions,
}); });
@ -253,7 +255,7 @@ const EditView = () => {
{ {
name: transferToken?.name || '', name: transferToken?.name || '',
description: transferToken?.description || '', description: transferToken?.description || '',
lifespan: transferToken?.lifespan ?? null, lifespan: transferToken?.lifespan || null,
/** /**
* We need to cast the permissions to satisfy the type for `permissions` * We need to cast the permissions to satisfy the type for `permissions`
* in the request body incase we don't have a transferToken and instead * in the request body incase we don't have a transferToken and instead

View File

@ -1,5 +1,5 @@
import crypto from 'crypto'; import crypto from 'crypto';
import { omit, difference, isNil, isEmpty, map, isArray, uniq } from 'lodash/fp'; import { omit, difference, isNil, isEmpty, map, isArray, uniq, isNumber } from 'lodash/fp';
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token'; import type { Update, ApiToken, ApiTokenBody } from '../../../shared/contracts/api-token';
import constants from './constants'; import constants from './constants';
@ -61,14 +61,25 @@ const assertCustomTokenPermissionsValidity = (
}; };
/** /**
* Assert that a token's lifespan is valid * Check if a token's lifespan is valid
*/ */
const assertValidLifespan = (lifespan: ApiTokenBody['lifespan']) => { const isValidLifespan = (lifespan: unknown) => {
if (isNil(lifespan)) { if (isNil(lifespan)) {
return; return true;
} }
if (!Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan as number)) { if (!isNumber(lifespan) || !Object.values(constants.API_TOKEN_LIFESPANS).includes(lifespan)) {
return false;
}
return true;
};
/**
* Assert that a token's lifespan is valid
*/
const assertValidLifespan = (lifespan: unknown) => {
if (!isValidLifespan(lifespan)) {
throw new ValidationError( throw new ValidationError(
`lifespan must be one of the following values: `lifespan must be one of the following values:
${Object.values(constants.API_TOKEN_LIFESPANS).join(', ')}` ${Object.values(constants.API_TOKEN_LIFESPANS).join(', ')}`
@ -138,14 +149,14 @@ const hash = (accessKey: string) => {
const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => { const getExpirationFields = (lifespan: ApiTokenBody['lifespan']) => {
// it must be nil or a finite number >= 0 // it must be nil or a finite number >= 0
const isValidNumber = Number.isFinite(lifespan) && (lifespan as number) > 0; const isValidNumber = isNumber(lifespan) && Number.isFinite(lifespan) && lifespan > 0;
if (!isValidNumber && !isNil(lifespan)) { if (!isValidNumber && !isNil(lifespan)) {
throw new ValidationError('lifespan must be a positive number or null'); throw new ValidationError('lifespan must be a positive number or null');
} }
return { return {
lifespan: lifespan || null, lifespan: lifespan || null,
expiresAt: lifespan ? Date.now() + (lifespan as number) : null, expiresAt: lifespan ? Date.now() + lifespan : null,
}; };
}; };

View File

@ -1,6 +1,6 @@
import crypto from 'crypto'; import crypto from 'crypto';
import assert from 'assert'; import assert from 'assert';
import { map, isArray, omit, uniq, isNil, difference, isEmpty } from 'lodash/fp'; import { map, isArray, omit, uniq, isNil, difference, isEmpty, isNumber } from 'lodash/fp';
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
import '@strapi/types'; import '@strapi/types';
import constants from '../constants'; import constants from '../constants';
@ -79,7 +79,7 @@ const create = async (attributes: TokenCreatePayload): Promise<TransferToken> =>
delete attributes.accessKey; delete attributes.accessKey;
assertTokenPermissionsValidity(attributes); assertTokenPermissionsValidity(attributes);
assertValidLifespan(attributes); assertValidLifespan(attributes.lifespan);
const result = (await strapi.db.transaction(async () => { const result = (await strapi.db.transaction(async () => {
const transferToken = await strapi.query(TRANSFER_TOKEN_UID).create({ const transferToken = await strapi.query(TRANSFER_TOKEN_UID).create({
@ -131,7 +131,7 @@ const update = async (
} }
assertTokenPermissionsValidity(attributes); assertTokenPermissionsValidity(attributes);
assertValidLifespan(attributes); assertValidLifespan(attributes.lifespan);
return strapi.db.transaction(async () => { return strapi.db.transaction(async () => {
const updatedToken = await strapi.query(TRANSFER_TOKEN_UID).update({ const updatedToken = await strapi.query(TRANSFER_TOKEN_UID).update({
@ -281,11 +281,9 @@ const regenerate = async (id: string | number): Promise<TransferToken> => {
}; };
}; };
const getExpirationFields = ( const getExpirationFields = (lifespan: TransferToken['lifespan']) => {
lifespan: number | null
): { lifespan: null | number; expiresAt: null | number } => {
// it must be nil or a finite number >= 0 // it must be nil or a finite number >= 0
const isValidNumber = Number.isFinite(lifespan) && lifespan !== null && lifespan > 0; const isValidNumber = isNumber(lifespan) && Number.isFinite(lifespan) && lifespan > 0;
if (!isValidNumber && !isNil(lifespan)) { if (!isValidNumber && !isNil(lifespan)) {
throw new ValidationError('lifespan must be a positive number or null'); throw new ValidationError('lifespan must be a positive number or null');
} }
@ -359,14 +357,28 @@ const assertTokenPermissionsValidity = (attributes: TokenUpdatePayload) => {
}; };
/** /**
* Assert that a token's lifespan is valid * Check if a token's lifespan is valid
*/ */
const assertValidLifespan = ({ lifespan }: { lifespan?: TransferToken['lifespan'] }) => { const isValidLifespan = (lifespan: unknown) => {
if (isNil(lifespan)) { if (isNil(lifespan)) {
return; return true;
} }
if (!Object.values(constants.TRANSFER_TOKEN_LIFESPANS).includes(lifespan)) { if (
!isNumber(lifespan) ||
!Object.values(constants.TRANSFER_TOKEN_LIFESPANS).includes(lifespan)
) {
return false;
}
return true;
};
/**
* Assert that a token's lifespan is valid
*/
const assertValidLifespan = (lifespan: unknown) => {
if (!isValidLifespan(lifespan)) {
throw new ValidationError( throw new ValidationError(
`lifespan must be one of the following values: `lifespan must be one of the following values:
${Object.values(constants.TRANSFER_TOKEN_LIFESPANS).join(', ')}` ${Object.values(constants.TRANSFER_TOKEN_LIFESPANS).join(', ')}`

View File

@ -8,7 +8,7 @@ export type ApiToken = {
expiresAt: string; expiresAt: string;
id: Entity.ID; id: Entity.ID;
lastUsedAt: string | null; lastUsedAt: string | null;
lifespan: string | number; lifespan: string | number | null;
name: string; name: string;
permissions: string[]; permissions: string[];
type: 'custom' | 'full-access' | 'read-only'; type: 'custom' | 'full-access' | 'read-only';

View File

@ -1,7 +1,7 @@
import { errors } from '@strapi/utils'; import { errors } from '@strapi/utils';
export interface TransferTokenPermission { export interface TransferTokenPermission {
id: number | string; id: number | `${number}`;
action: 'push' | 'pull' | 'push-pull'; action: 'push' | 'pull' | 'push-pull';
token: TransferToken | number; token: TransferToken | number;
} }
@ -12,7 +12,7 @@ export interface DatabaseTransferToken {
description: string; description: string;
accessKey: string; accessKey: string;
lastUsedAt?: number; lastUsedAt?: number;
lifespan: number | null; lifespan: string | number | null;
expiresAt: number; expiresAt: number;
permissions: TransferTokenPermission[]; permissions: TransferTokenPermission[];
} }

View File

@ -298,6 +298,9 @@ const TimezoneComponent = ({ timezoneOptions }: { timezoneOptions: ITimezoneOpti
onChange={(timezone) => { onChange={(timezone) => {
setFieldValue('timezone', timezone); setFieldValue('timezone', timezone);
}} }}
onTextValueChange={(timezone) => {
setFieldValue('timezone', timezone);
}}
onClear={() => { onClear={() => {
setFieldValue('timezone', ''); setFieldValue('timezone', '');
}} }}

View File

@ -60,6 +60,8 @@ import {
import { useTypedDispatch } from '../store/hooks'; import { useTypedDispatch } from '../store/hooks';
import { getTimezoneOffset } from '../utils/time'; import { getTimezoneOffset } from '../utils/time';
import { getBadgeProps } from './ReleasesPage';
import type { import type {
ReleaseAction, ReleaseAction,
ReleaseActionGroupBy, ReleaseActionGroupBy,
@ -78,13 +80,20 @@ const ReleaseInfoWrapper = styled(Flex)`
border-top: 1px solid ${({ theme }) => theme.colors.neutral150}; border-top: 1px solid ${({ theme }) => theme.colors.neutral150};
`; `;
const StyledMenuItem = styled(Menu.Item)<{ disabled?: boolean }>` const StyledMenuItem = styled(Menu.Item)<{
disabled?: boolean;
variant?: 'neutral' | 'danger';
}>`
svg path { svg path {
fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500}; fill: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
} }
span { span {
color: ${({ theme, disabled }) => disabled && theme.colors.neutral500}; color: ${({ theme, disabled }) => disabled && theme.colors.neutral500};
} }
&:hover {
background: ${({ theme, variant = 'neutral' }) => theme.colors[`${variant}100`]};
}
`; `;
const PencilIcon = styled(Pencil)` const PencilIcon = styled(Pencil)`
@ -350,7 +359,13 @@ const ReleaseDetailsLayout = ({
<HeaderLayout <HeaderLayout
title={release.name} title={release.name}
subtitle={ subtitle={
numberOfEntriesText + (IsSchedulingEnabled && isScheduled ? ` - ${scheduledText}` : '') <Flex gap={2} lineHeight={6}>
<Typography textColor="neutral600" variant="epsilon">
{numberOfEntriesText +
(IsSchedulingEnabled && isScheduled ? ` - ${scheduledText}` : '')}
</Typography>
<Badge {...getBadgeProps(release.status)}>{release.status}</Badge>
</Flex>
} }
navigationAction={ navigationAction={
<Link startIcon={<ArrowLeft />} to="/plugins/content-releases"> <Link startIcon={<ArrowLeft />} to="/plugins/content-releases">
@ -394,14 +409,7 @@ const ReleaseDetailsLayout = ({
width="100%" width="100%"
> >
<StyledMenuItem disabled={!canUpdate} onSelect={toggleEditReleaseModal}> <StyledMenuItem disabled={!canUpdate} onSelect={toggleEditReleaseModal}>
<Flex <Flex alignItems="center" gap={2} hasRadius width="100%">
paddingTop={2}
paddingBottom={2}
alignItems="center"
gap={2}
hasRadius
width="100%"
>
<PencilIcon /> <PencilIcon />
<Typography ellipsis> <Typography ellipsis>
{formatMessage({ {formatMessage({
@ -411,15 +419,12 @@ const ReleaseDetailsLayout = ({
</Typography> </Typography>
</Flex> </Flex>
</StyledMenuItem> </StyledMenuItem>
<StyledMenuItem disabled={!canDelete} onSelect={toggleWarningSubmit}> <StyledMenuItem
<Flex disabled={!canDelete}
paddingTop={2} onSelect={toggleWarningSubmit}
paddingBottom={2} variant="danger"
alignItems="center" >
gap={2} <Flex alignItems="center" gap={2} hasRadius width="100%">
hasRadius
width="100%"
>
<TrashIcon /> <TrashIcon />
<Typography ellipsis textColor="danger600"> <Typography ellipsis textColor="danger600">
{formatMessage({ {formatMessage({

View File

@ -4,6 +4,7 @@ import * as React from 'react';
import { useLicenseLimits } from '@strapi/admin/strapi-admin'; import { useLicenseLimits } from '@strapi/admin/strapi-admin';
import { import {
Alert, Alert,
Badge,
Box, Box,
Button, Button,
ContentLayout, ContentLayout,
@ -39,7 +40,7 @@ import { useIntl } from 'react-intl';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import styled from 'styled-components'; import styled from 'styled-components';
import { GetReleases } from '../../../shared/contracts/releases'; import { GetReleases, type Release } from '../../../shared/contracts/releases';
import { ReleaseModal, FormValues } from '../components/ReleaseModal'; import { ReleaseModal, FormValues } from '../components/ReleaseModal';
import { PERMISSIONS } from '../constants'; import { PERMISSIONS } from '../constants';
import { isAxiosError } from '../services/axios'; import { isAxiosError } from '../services/axios';
@ -62,6 +63,37 @@ const LinkCard = styled(Link)`
display: block; display: block;
`; `;
const CapitalizeRelativeTime = styled(RelativeTime)`
text-transform: capitalize;
`;
const getBadgeProps = (status: Release['status']) => {
let color;
switch (status) {
case 'ready':
color = 'success';
break;
case 'blocked':
color = 'warning';
break;
case 'failed':
color = 'danger';
break;
case 'done':
color = 'primary';
break;
case 'empty':
default:
color = 'neutral';
}
return {
textColor: `${color}600`,
backgroundColor: `${color}100`,
borderColor: `${color}200`,
};
};
const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: ReleasesGridProps) => { const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: ReleasesGridProps) => {
const { formatMessage } = useIntl(); const { formatMessage } = useIntl();
const IsSchedulingEnabled = window.strapi.future.isEnabled('contentReleasesScheduling'); const IsSchedulingEnabled = window.strapi.future.isEnabled('contentReleasesScheduling');
@ -89,7 +121,7 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
return ( return (
<Grid gap={4}> <Grid gap={4}>
{releases.map(({ id, name, actions, scheduledAt }) => ( {releases.map(({ id, name, actions, scheduledAt, status }) => (
<GridItem col={3} s={6} xs={12} key={id}> <GridItem col={3} s={6} xs={12} key={id}>
<LinkCard href={`content-releases/${id}`} isExternal={false}> <LinkCard href={`content-releases/${id}`} isExternal={false}>
<Flex <Flex
@ -102,32 +134,35 @@ const ReleasesGrid = ({ sectionTitle, releases = [], isError = false }: Releases
height="100%" height="100%"
width="100%" width="100%"
alignItems="start" alignItems="start"
gap={2} gap={4}
> >
<Typography as="h3" variant="delta" fontWeight="bold"> <Flex direction="column" alignItems="start" gap={1}>
{name} <Typography as="h3" variant="delta" fontWeight="bold">
</Typography> {name}
<Typography variant="pi" textColor="neutral600"> </Typography>
{IsSchedulingEnabled ? ( <Typography variant="pi" textColor="neutral600">
scheduledAt ? ( {IsSchedulingEnabled ? (
<RelativeTime timestamp={new Date(scheduledAt)} /> scheduledAt ? (
<CapitalizeRelativeTime timestamp={new Date(scheduledAt)} />
) : (
formatMessage({
id: 'content-releases.pages.Releases.not-scheduled',
defaultMessage: 'Not scheduled',
})
)
) : ( ) : (
formatMessage({ formatMessage(
id: 'content-releases.pages.Releases.not-scheduled', {
defaultMessage: 'Not scheduled', id: 'content-releases.page.Releases.release-item.entries',
}) defaultMessage:
) '{number, plural, =0 {No entries} one {# entry} other {# entries}}',
) : ( },
formatMessage( { number: actions.meta.count }
{ )
id: 'content-releases.page.Releases.release-item.entries', )}
defaultMessage: </Typography>
'{number, plural, =0 {No entries} one {# entry} other {# entries}}', </Flex>
}, <Badge {...getBadgeProps(status)}>{status}</Badge>
{ number: actions.meta.count }
)
)}
</Typography>
</Flex> </Flex>
</LinkCard> </LinkCard>
</GridItem> </GridItem>
@ -402,4 +437,4 @@ const ReleasesPage = () => {
); );
}; };
export { ReleasesPage }; export { ReleasesPage, getBadgeProps };

View File

@ -56,6 +56,9 @@ describe('Releases details page', () => {
const releaseSubtitle = await screen.findAllByText('No entries'); const releaseSubtitle = await screen.findAllByText('No entries');
expect(releaseSubtitle[0]).toBeInTheDocument(); expect(releaseSubtitle[0]).toBeInTheDocument();
const releaseStatus = screen.getByText('empty');
expect(releaseStatus).toBeInTheDocument();
const moreButton = screen.getByRole('button', { name: 'Release edit and delete menu' }); const moreButton = screen.getByRole('button', { name: 'Release edit and delete menu' });
expect(moreButton).toBeInTheDocument(); expect(moreButton).toBeInTheDocument();
@ -160,7 +163,7 @@ describe('Releases details page', () => {
expect(tables).toHaveLength(2); expect(tables).toHaveLength(2);
}); });
it('shows the right status', async () => { it('shows the right status for unpublished release', async () => {
server.use( server.use(
rest.get('/content-releases/:releaseId', (req, res, ctx) => rest.get('/content-releases/:releaseId', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData)) res(ctx.json(mockReleaseDetailsPageData.withActionsHeaderData))
@ -187,6 +190,10 @@ describe('Releases details page', () => {
); );
expect(releaseTitle).toBeInTheDocument(); expect(releaseTitle).toBeInTheDocument();
const releaseStatus = screen.getByText('ready');
expect(releaseStatus).toBeInTheDocument();
expect(releaseStatus).toHaveStyle(`color: #328048`);
const cat1Row = screen.getByRole('row', { name: /cat1/i }); const cat1Row = screen.getByRole('row', { name: /cat1/i });
expect(within(cat1Row).getByRole('gridcell', { name: 'Ready to publish' })).toBeInTheDocument(); expect(within(cat1Row).getByRole('gridcell', { name: 'Ready to publish' })).toBeInTheDocument();
@ -200,4 +207,36 @@ describe('Releases details page', () => {
within(add1Row).getByRole('gridcell', { name: 'Already published' }) within(add1Row).getByRole('gridcell', { name: 'Already published' })
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('shows the right release status for published release', async () => {
server.use(
rest.get('/content-releases/:releaseId', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withActionsAndPublishedHeaderData))
)
);
server.use(
rest.get('/content-releases/:releaseId/actions', (req, res, ctx) =>
res(ctx.json(mockReleaseDetailsPageData.withMultipleActionsBodyData))
)
);
render(
<Routes>
<Route path="/content-releases/:releaseId" element={<ReleaseDetailsPage />} />
</Routes>,
{
initialEntries: [{ pathname: `/content-releases/3` }],
}
);
const releaseTitle = await screen.findByText(
mockReleaseDetailsPageData.withActionsAndPublishedHeaderData.data.name
);
expect(releaseTitle).toBeInTheDocument();
const releaseStatus = screen.getByText('done');
expect(releaseStatus).toBeInTheDocument();
expect(releaseStatus).toHaveStyle(`color: #4945ff`);
});
}); });

View File

@ -9,6 +9,7 @@ const RELEASE_NO_ACTIONS_HEADER_MOCK_DATA = {
createdAt: '2023-11-16T15:18:32.560Z', createdAt: '2023-11-16T15:18:32.560Z',
updatedAt: '2023-11-16T15:18:32.560Z', updatedAt: '2023-11-16T15:18:32.560Z',
releasedAt: null, releasedAt: null,
status: 'empty',
createdBy: { createdBy: {
id: 1, id: 1,
firstname: 'Admin', firstname: 'Admin',
@ -50,6 +51,7 @@ const RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
createdAt: '2023-11-16T15:18:32.560Z', createdAt: '2023-11-16T15:18:32.560Z',
updatedAt: '2023-11-16T15:18:32.560Z', updatedAt: '2023-11-16T15:18:32.560Z',
releasedAt: null, releasedAt: null,
status: 'ready',
createdBy: { createdBy: {
id: 1, id: 1,
firstname: 'Admin', firstname: 'Admin',
@ -70,11 +72,12 @@ const RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
const PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = { const PUBLISHED_RELEASE_WITH_ACTIONS_HEADER_MOCK_DATA = {
data: { data: {
id: 2, id: 3,
name: 'release with actions', name: 'release with actions',
createdAt: '2023-11-16T15:18:32.560Z', createdAt: '2023-11-16T15:18:32.560Z',
updatedAt: '2023-11-16T15:18:32.560Z', updatedAt: '2023-11-16T15:18:32.560Z',
releasedAt: '2023-11-16T15:18:32.560Z', releasedAt: '2023-11-16T15:18:32.560Z',
status: 'done',
createdBy: { createdBy: {
id: 1, id: 1,
firstname: 'Admin', firstname: 'Admin',

View File

@ -12,6 +12,7 @@ interface CustomInterval {
export interface RelativeTimeProps { export interface RelativeTimeProps {
timestamp: Date; timestamp: Date;
customIntervals?: CustomInterval[]; customIntervals?: CustomInterval[];
className?: string;
} }
/** /**
@ -28,7 +29,7 @@ export interface RelativeTimeProps {
* ]} * ]}
* ``` * ```
*/ */
const RelativeTime = ({ timestamp, customIntervals = [] }: RelativeTimeProps) => { const RelativeTime = ({ timestamp, customIntervals = [], className }: RelativeTimeProps) => {
const { formatRelativeTime, formatDate, formatTime } = useIntl(); const { formatRelativeTime, formatDate, formatTime } = useIntl();
const interval = intervalToDuration({ const interval = intervalToDuration({
@ -54,6 +55,7 @@ const RelativeTime = ({ timestamp, customIntervals = [] }: RelativeTimeProps) =>
<time <time
dateTime={timestamp.toISOString()} dateTime={timestamp.toISOString()}
title={`${formatDate(timestamp)} ${formatTime(timestamp)}`} title={`${formatDate(timestamp)} ${formatTime(timestamp)}`}
className={className}
> >
{displayText} {displayText}
</time> </time>

View File

@ -1,5 +1,6 @@
// @ts-check // @ts-check
const { devices } = require('@playwright/test'); const { devices } = require('@playwright/test');
const { parseType } = require('@strapi/utils');
const getEnvNum = (envVar, defaultValue) => { const getEnvNum = (envVar, defaultValue) => {
if (envVar !== undefined && envVar !== null) { if (envVar !== undefined && envVar !== null) {
@ -8,6 +9,22 @@ const getEnvNum = (envVar, defaultValue) => {
return defaultValue; return defaultValue;
}; };
const getEnvString = (envVar, defaultValue) => {
if (envVar?.trim().length) {
return envVar;
}
return defaultValue;
};
const getEnvBool = (envVar, defaultValue) => {
if (!envVar || envVar === '') {
return defaultValue;
}
return parseType({ type: 'boolean', value: envVar.toLowerCase() });
};
/** /**
* @typedef ConfigOptions * @typedef ConfigOptions
* @type {{ port: number; testDir: string; appDir: string }} * @type {{ port: number; testDir: string; appDir: string }}
@ -28,7 +45,7 @@ const createConfig = ({ port, testDir, appDir }) => ({
* Maximum time expect() should wait for the condition to be met. * Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();` * For example in `await expect(locator).toHaveText();`
*/ */
timeout: getEnvNum(process.env.PLAYWRIGHT_EXPECT_TIMEOUT, 30 * 1000), timeout: getEnvNum(process.env.PLAYWRIGHT_EXPECT_TIMEOUT, 10 * 1000),
}, },
/* Run tests in files in parallel */ /* Run tests in files in parallel */
fullyParallel: false, fullyParallel: false,
@ -46,13 +63,22 @@ const createConfig = ({ port, testDir, appDir }) => ({
baseURL: `http://127.0.0.1:${port}`, baseURL: `http://127.0.0.1:${port}`,
/* Default time each action such as `click()` can take to 20s */ /* Default time each action such as `click()` can take to 20s */
actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 20 * 1000), actionTimeout: getEnvNum(process.env.PLAYWRIGHT_ACTION_TIMEOUT, 15 * 1000),
/* Collect trace when a test failed on the CI. See https://playwright.dev/docs/trace-viewer /* 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, Until https://github.com/strapi/strapi/issues/18196 is fixed we can't enable this locally,
because the Strapi server restarts every time a new file (trace) is created. because the Strapi server restarts every time a new file (trace) is created.
*/ */
trace: 'off', trace: 'retain-on-failure',
video: getEnvBool(process.env.PLAYWRIGHT_VIDEO, false)
? {
mode: 'retain-on-failure', // 'retain-on-failure' to save videos only for failed tests
size: {
width: 1280,
height: 720,
},
}
: 'off',
}, },
/* Configure projects for major browsers */ /* Configure projects for major browsers */
@ -80,7 +106,7 @@ const createConfig = ({ port, testDir, appDir }) => ({
], ],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */ /* Folder for test artifacts such as screenshots, videos, traces, etc. */
outputDir: 'test-results/', outputDir: getEnvString(process.env.PLAYWRIGHT_OUTPUT_DIR, '../test-results/'), // in the test-apps/e2e dir, to avoid writing files to the running Strapi project dir
/* Run your local dev server before starting the tests */ /* Run your local dev server before starting the tests */
webServer: { webServer: {