mirror of
https://github.com/strapi/strapi.git
synced 2025-07-19 15:06:11 +00:00
Feat(content-releases): release status badge (#19611)
* feat(content-releases): add status to releases * add docs and fix e2e error * draft: added basic badge with default value * Update docs/docs/docs/01-core/content-releases/00-intro.md Co-authored-by: Simone <startae14@gmail.com> * Update docs/docs/docs/01-core/content-releases/00-intro.md Co-authored-by: Simone <startae14@gmail.com> * Update docs/docs/docs/01-core/content-releases/00-intro.md Co-authored-by: Simone <startae14@gmail.com> * apply marks feedback * don't throw error on lifecycle hooks inside releases * handle when actions are not valid anymore * await for entry validation on releases edit entry * check if are changes in content types attributes to revalidate * fix e2e test * apply marks feedback * fix: removed default status value * fix: release card design updated, capitalize scheduled period * fix: e2e test updated to select always a current date --------- Co-authored-by: Fernando Chavez <fernando.chavez@strapi.io> Co-authored-by: Simone <startae14@gmail.com>
This commit is contained in:
parent
73143c2805
commit
ad6d81cb6c
@ -70,7 +70,15 @@ describeOnCondition(edition === 'EE')('Releases page', () => {
|
|||||||
name: 'Date',
|
name: 'Date',
|
||||||
})
|
})
|
||||||
.click();
|
.click();
|
||||||
await page.getByRole('gridcell', { name: 'Sunday, March 3, 2024' }).click();
|
|
||||||
|
const formattedDate = new 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', {
|
||||||
|
@ -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,
|
||||||
@ -350,7 +352,13 @@ export 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">
|
||||||
|
@ -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 { useHistory, useLocation } from 'react-router-dom';
|
import { useHistory, 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>
|
||||||
@ -405,4 +440,4 @@ const ReleasesPage = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export { ReleasesPage };
|
export { ReleasesPage, getBadgeProps };
|
||||||
|
@ -52,6 +52,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();
|
||||||
|
|
||||||
@ -146,7 +149,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))
|
||||||
@ -160,7 +163,7 @@ describe('Releases details page', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
render(<ReleaseDetailsPage />, {
|
render(<ReleaseDetailsPage />, {
|
||||||
initialEntries: [{ pathname: `/content-releases/1` }],
|
initialEntries: [{ pathname: `/content-releases/2` }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const releaseTitle = await screen.findByText(
|
const releaseTitle = await screen.findByText(
|
||||||
@ -168,6 +171,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();
|
||||||
|
|
||||||
@ -181,4 +188,31 @@ 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(<ReleaseDetailsPage />, {
|
||||||
|
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`);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -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',
|
||||||
|
@ -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>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user