enhancement: create dedicated preview page (#21965)

* enh: add preview page and navbar

* chore: remove header

* chore: update e2e tests

* chore: remove getDocumentStatus export

* fix: error state
This commit is contained in:
Rémi de Juvigny 2024-10-24 18:15:35 +02:00 committed by GitHub
parent c5ae9675f5
commit 6d1431fe2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 248 additions and 75 deletions

View File

@ -226,5 +226,5 @@ const BackButton = React.forwardRef<HTMLAnchorElement, BackButtonProps>(({ disab
);
});
export { BackButton, HistoryProvider };
export { BackButton, HistoryProvider, useHistory };
export type { BackButtonProps, HistoryProviderProps, HistoryContextValue, HistoryState };

View File

@ -37,6 +37,7 @@ export {
} from './features/Notifications';
export { useAppInfo, type AppInfoContextValue } from './features/AppInfo';
export { type Permission, useAuth, type AuthContextValue } from './features/Auth';
export { useHistory } from './features/BackButton';
/**
* Hooks

View File

@ -14,7 +14,6 @@ import { stringify } from 'qs';
import { useIntl } from 'react-intl';
import { NavLink, useNavigate, useParams, type To } from 'react-router-dom';
import { COLLECTION_TYPES } from '../../constants/collections';
import { PERMISSIONS } from '../../constants/plugin';
import { useHistoryContext } from '../pages/History';
import { useRestoreVersionMutation } from '../services/historyVersion';
@ -48,13 +47,6 @@ export const VersionHeader = ({ headerId }: VersionHeaderProps) => {
const getNextNavigation = (): To => {
const pluginsQueryParams = stringify({ plugins: query.plugins }, { encode: false });
if (collectionType === COLLECTION_TYPES) {
return {
pathname: '..',
search: pluginsQueryParams,
};
}
return {
pathname: '..',
search: pluginsQueryParams,

View File

@ -1,12 +1,6 @@
import * as React from 'react';
import {
useQueryParams,
Page,
createContext,
useRBAC,
BackButton,
} from '@strapi/admin/strapi-admin';
import { useQueryParams, Page, createContext, useRBAC } from '@strapi/admin/strapi-admin';
import { Box, Flex, FocusTrap, Main, Portal, Link } from '@strapi/design-system';
import { stringify } from 'qs';
import { useIntl } from 'react-intl';

View File

@ -1,9 +1,9 @@
/* eslint-disable check-file/filename-naming-convention */
import { lazy } from 'react';
import * as React from 'react';
import { type PathRouteProps } from 'react-router-dom';
const ProtectedHistoryPage = lazy(() =>
const ProtectedHistoryPage = React.lazy(() =>
import('./pages/History').then((mod) => ({ default: mod.ProtectedHistoryPage }))
);

View File

@ -149,7 +149,7 @@ const EditViewPage = () => {
return (
<Main paddingLeft={10} paddingRight={10}>
<Page.Title>{`${documentTitle}`}</Page.Title>
<Page.Title>{documentTitle}</Page.Title>
<Form
disabled={hasDraftAndPublished && status === 'published'}
initialValues={initialValues}
@ -228,7 +228,7 @@ const StatusTab = styled(Tabs.Trigger)`
/**
* @internal
* @description Returns the status of the document where it's latest state takes priority,
* @description Returns the status of the document where its latest state takes priority,
* this typically will be "published" unless a user has edited their draft in which we should
* display "modified".
*/

View File

@ -1,9 +1,9 @@
import * as React from 'react';
import { useClipboard, useNotification, useTracking } from '@strapi/admin/strapi-admin';
import { Button, Flex, IconButton } from '@strapi/design-system';
import { Link as LinkIcon } from '@strapi/icons';
import { useQueryParams, useTracking } from '@strapi/admin/strapi-admin';
import { Button, Flex } from '@strapi/design-system';
import { UID } from '@strapi/types';
import { stringify } from 'qs';
import { useIntl } from 'react-intl';
import { Link } from 'react-router-dom';
@ -13,9 +13,14 @@ import type { PanelComponent } from '@strapi/content-manager/strapi-admin';
const PreviewSidePanel: PanelComponent = ({ model, documentId, document }) => {
const { formatMessage } = useIntl();
const { toggleNotification } = useNotification();
const { copy } = useClipboard();
const { trackUsage } = useTracking();
const [{ query }] = useQueryParams();
/**
* The preview URL isn't used in this component, we just fetch it to know if preview is enabled
* for the content type. If it's not, the panel is not displayed. If it is, we display a link to
* /preview, and the URL will already be loaded in the RTK query cache.
*/
const { data, error } = useGetPreviewUrlQuery({
params: {
contentType: model as UID.ContentType,
@ -31,20 +36,8 @@ const PreviewSidePanel: PanelComponent = ({ model, documentId, document }) => {
return null;
}
const { url } = data.data;
const handleCopyLink = () => {
copy(url);
toggleNotification({
message: formatMessage({
id: 'content-manager.preview.copy.success',
defaultMessage: 'Copied preview link',
}),
type: 'success',
});
};
const handleClick = () => {
// TODO: delete this event and use willNavigate instead
trackUsage('willOpenPreview');
};
@ -55,9 +48,8 @@ const PreviewSidePanel: PanelComponent = ({ model, documentId, document }) => {
<Button
variant="tertiary"
tag={Link}
to={url}
to={{ pathname: 'preview', search: stringify(query, { encode: false }) }}
onClick={handleClick}
target="_blank"
flex="auto"
>
{formatMessage({
@ -65,16 +57,6 @@ const PreviewSidePanel: PanelComponent = ({ model, documentId, document }) => {
defaultMessage: 'Open preview',
})}
</Button>
<IconButton
type="button"
label={formatMessage({
id: 'preview.copy.label',
defaultMessage: 'Copy preview link',
})}
onClick={handleCopyLink}
>
<LinkIcon />
</IconButton>
</Flex>
),
};

View File

@ -0,0 +1,200 @@
import * as React from 'react';
import { Page, useQueryParams, useRBAC, createContext } from '@strapi/admin/strapi-admin';
import { Box, FocusTrap, Portal, Typography } from '@strapi/design-system';
import { useIntl } from 'react-intl';
import { useParams } from 'react-router-dom';
import { GetPreviewUrl } from '../../../../shared/contracts/preview';
import { COLLECTION_TYPES } from '../../constants/collections';
import { DocumentRBAC } from '../../features/DocumentRBAC';
import { type UseDocument, useDocument } from '../../hooks/useDocument';
import { useDocumentLayout } from '../../hooks/useDocumentLayout';
import { buildValidParams } from '../../utils/api';
import { useGetPreviewUrlQuery } from '../services/preview';
import type { UID } from '@strapi/types';
/* -------------------------------------------------------------------------------------------------
* PreviewProvider
* -----------------------------------------------------------------------------------------------*/
interface PreviewContextValue {
url: string;
mainField: string;
document: NonNullable<ReturnType<UseDocument>['document']>;
meta: NonNullable<ReturnType<UseDocument>['meta']>;
schema: NonNullable<ReturnType<UseDocument>['schema']>;
}
const [PreviewProvider, usePreviewContext] = createContext<PreviewContextValue>('PreviewPage');
/* -------------------------------------------------------------------------------------------------
* PreviewPage
* -----------------------------------------------------------------------------------------------*/
const PreviewPage = () => {
const { formatMessage } = useIntl();
// Read all the necessary data from the URL to find the right preview URL
const {
slug: model,
id: documentId,
collectionType,
} = useParams<{
slug: UID.ContentType;
id: string;
collectionType: string;
}>();
const [{ query }] = useQueryParams<{
plugins?: Record<string, unknown>;
}>();
const params = React.useMemo(() => buildValidParams(query), [query]);
if (!collectionType) {
throw new Error('Could not find collectionType in url params');
}
if (!model) {
throw new Error('Could not find model in url params');
}
// Only collection types must have a documentId
if (collectionType === COLLECTION_TYPES && !documentId) {
throw new Error('Could not find documentId in url params');
}
const previewUrlResponse = useGetPreviewUrlQuery({
params: {
contentType: model,
},
query: {
documentId,
locale: params.locale,
status: params.status as GetPreviewUrl.Request['query']['status'],
},
});
const documentResponse = useDocument({
model,
collectionType,
documentId,
params,
});
const documentLayoutResponse = useDocumentLayout(model);
if (
documentResponse.isLoading ||
previewUrlResponse.isLoading ||
documentLayoutResponse.isLoading
) {
return <Page.Loading />;
}
if (
previewUrlResponse.error ||
documentLayoutResponse.error ||
!documentResponse.document ||
!documentResponse.meta ||
!documentResponse.schema
) {
return <Page.Error />;
}
if (!previewUrlResponse.data?.data?.url) {
return <Page.NoData />;
}
return (
<>
<Page.Title>
{formatMessage(
{
id: 'content-manager.preview.page-title',
defaultMessage: '{contentType} preview',
},
{
contentType: documentLayoutResponse.edit.settings.displayName,
}
)}
</Page.Title>
<PreviewProvider
url={previewUrlResponse.data.data.url}
mainField={documentLayoutResponse.edit.settings.mainField}
document={documentResponse.document}
meta={documentResponse.meta}
schema={documentResponse.schema}
>
<Typography>Preview will go here!</Typography>
</PreviewProvider>
</>
);
};
/* -------------------------------------------------------------------------------------------------
* ProtectedPreviewPage
* -----------------------------------------------------------------------------------------------*/
const ProtectedPreviewPageImpl = () => {
const { slug: model } = useParams<{
slug: string;
}>();
const {
permissions = [],
isLoading,
error,
} = useRBAC([{ action: 'plugin::content-manager.explorer.read', subject: model }]);
if (isLoading) {
return <Page.Loading />;
}
if (error || !model) {
return (
<Box
height="100vh"
width="100vw"
position="fixed"
top={0}
left={0}
zIndex={2}
background="neutral0"
>
<Page.Error />
</Box>
);
}
return (
<Box
height="100vh"
width="100vw"
position="fixed"
top={0}
left={0}
zIndex={2}
background="neutral0"
>
<Page.Protect permissions={permissions}>
{({ permissions }) => (
<DocumentRBAC permissions={permissions}>
<PreviewPage />
</DocumentRBAC>
)}
</Page.Protect>
</Box>
);
};
const ProtectedPreviewPage = () => {
return (
<Portal>
<FocusTrap>
<ProtectedPreviewPageImpl />
</FocusTrap>
</Portal>
);
};
export { ProtectedPreviewPage, usePreviewContext };

View File

@ -0,0 +1,21 @@
/* eslint-disable check-file/filename-naming-convention */
import * as React from 'react';
import type { PathRouteProps } from 'react-router-dom';
const ProtectedPreviewPage = React.lazy(() =>
import('./pages/Preview').then((mod) => ({ default: mod.ProtectedPreviewPage }))
);
const routes: PathRouteProps[] = [
{
path: ':collectionType/:slug/:id/preview',
Component: ProtectedPreviewPage,
},
{
path: ':collectionType/:slug/preview',
Component: ProtectedPreviewPage,
},
];
export { routes };

View File

@ -5,6 +5,7 @@ import { Navigate, PathRouteProps, useParams } from 'react-router-dom';
import { COLLECTION_TYPES, SINGLE_TYPES } from './constants/collections';
import { routes as historyRoutes } from './history/routes';
import { routes as previewRoutes } from './preview/routes';
const ProtectedEditViewPage = lazy(() =>
import('./pages/EditView/EditViewPage').then((mod) => ({ default: mod.ProtectedEditViewPage }))
@ -90,6 +91,7 @@ const routes: PathRouteProps[] = [
Component: NoContentType,
},
...historyRoutes,
...previewRoutes,
];
export { routes, CLONE_PATH, LIST_PATH };

View File

@ -233,6 +233,8 @@
"popover.display-relations.label": "Display relations",
"preview.panel.title": "Preview",
"preview.panel.button": "Open preview",
"preview.page-title": "{contentType} preview",
"preview.header.close": "Close preview",
"preview.copy.label": "Copy preview link",
"preview.copy.success": "Copied preview link",
"relation.add": "Add relation",

View File

@ -16,36 +16,15 @@ describeOnCondition(edition === 'EE')('Preview', () => {
await page.waitForURL('/admin');
});
test('Preview button should appear for configured content types', async ({
page,
context,
browser,
}) => {
test('Preview button should appear for configured content types', async ({ page, context }) => {
// Open an edit view for a content type that has preview
await clickAndWait(page, page.getByRole('link', { name: 'Content Manager' }));
await clickAndWait(page, page.getByRole('link', { name: 'Article' }));
await clickAndWait(page, page.getByRole('gridcell', { name: /west ham post match/i }));
// Copy the preview link
await page.getByRole('button', { name: /copy preview link/i }).click();
await findAndClose(page, 'Copied preview link');
// Check that preview opens in a new tab
const newTabPromiseDraft = page.waitForEvent('popup');
await page.getByRole('link', { name: /open preview/i }).click();
const newTab = await newTabPromiseDraft;
expect(newTab.url()).toMatch(/^https:\/\/strapi\.io\/preview\/api::article\.article.*\/draft$/);
// Check that preview link reflects the publication status
await page.getByRole('button', { name: /publish/i }).click();
await findAndClose(page, 'Published document');
await page.getByRole('tab', { name: /published/i }).click();
const newTabPromisePublished = page.waitForEvent('popup');
await page.getByRole('link', { name: /open preview/i }).click();
const newTab2 = await newTabPromisePublished;
expect(newTab2.url()).toMatch(
/^https:\/\/strapi\.io\/preview\/api::article\.article.*\/published$/
);
// Check that preview opens in its own page
await clickAndWait(page, page.getByRole('link', { name: /open preview/i }));
await expect(page.getByText('Preview will go here!')).toBeVisible();
});
test('Preview button should not appear for content types without preview config', async ({