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:
Madhuri Sandbhor 2024-03-04 11:50:38 +01:00 committed by GitHub
parent 73143c2805
commit ad6d81cb6c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 123 additions and 33 deletions

View File

@ -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', {

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,
@ -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">

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 { 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 };

View File

@ -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`);
});
}); });

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>