mirror of
https://github.com/strapi/strapi.git
synced 2025-12-25 06:04:29 +00:00
enhancement: add back button fallback support (#21970)
* enhancement: add back button fallback support * enhancement: add fallback urls * fix: feedback and fixes
This commit is contained in:
parent
b0db56479d
commit
fed213989e
@ -4,7 +4,7 @@ import { Link, LinkProps } from '@strapi/design-system';
|
||||
import { ArrowLeft } from '@strapi/icons';
|
||||
import { produce } from 'immer';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { NavLink, type To, useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import { createContext } from '../components/Context';
|
||||
|
||||
@ -188,43 +188,61 @@ const reducer = (state: HistoryState, action: HistoryActions) =>
|
||||
/* -------------------------------------------------------------------------------------------------
|
||||
* BackButton
|
||||
* -----------------------------------------------------------------------------------------------*/
|
||||
interface BackButtonProps extends Pick<LinkProps, 'disabled'> {}
|
||||
interface BackButtonProps extends Pick<LinkProps, 'disabled'> {
|
||||
fallback?: To;
|
||||
}
|
||||
|
||||
/**
|
||||
* @beta
|
||||
* @description The universal back button for the Strapi application. This uses the internal history
|
||||
* context to navigate the user back to the previous location. It can be completely disabled in a
|
||||
* specific user case.
|
||||
* specific user case. When no history is available, you can provide a fallback destination,
|
||||
* otherwise the link will be disabled.
|
||||
*/
|
||||
const BackButton = React.forwardRef<HTMLAnchorElement, BackButtonProps>(({ disabled }, ref) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const BackButton = React.forwardRef<HTMLAnchorElement, BackButtonProps>(
|
||||
({ disabled, fallback = '' }, ref) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const canGoBack = useHistory('BackButton', (state) => state.canGoBack);
|
||||
const goBack = useHistory('BackButton', (state) => state.goBack);
|
||||
const history = useHistory('BackButton', (state) => state.history);
|
||||
const canGoBack = useHistory('BackButton', (state) => state.canGoBack);
|
||||
const goBack = useHistory('BackButton', (state) => state.goBack);
|
||||
const history = useHistory('BackButton', (state) => state.history);
|
||||
const hasFallback = fallback !== '';
|
||||
const shouldBeDisabled = disabled || (!canGoBack && !hasFallback);
|
||||
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
};
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
e.preventDefault();
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
tag={NavLink}
|
||||
to={history.at(-1) ?? ''}
|
||||
onClick={handleClick}
|
||||
disabled={disabled || !canGoBack}
|
||||
aria-disabled={disabled || !canGoBack}
|
||||
startIcon={<ArrowLeft />}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'global.back',
|
||||
defaultMessage: 'Back',
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
});
|
||||
if (canGoBack) {
|
||||
goBack();
|
||||
} else if (hasFallback) {
|
||||
navigate(fallback);
|
||||
}
|
||||
};
|
||||
|
||||
// The link destination from the history. Undefined if there is only 1 location in the history.
|
||||
const historyTo = canGoBack ? history.at(-1) : undefined;
|
||||
// If no link destination from the history, use the fallback.
|
||||
const toWithFallback = historyTo ?? fallback;
|
||||
|
||||
return (
|
||||
<Link
|
||||
ref={ref}
|
||||
tag={NavLink}
|
||||
to={toWithFallback}
|
||||
onClick={handleClick}
|
||||
disabled={shouldBeDisabled}
|
||||
aria-disabled={shouldBeDisabled}
|
||||
startIcon={<ArrowLeft />}
|
||||
>
|
||||
{formatMessage({
|
||||
id: 'global.back',
|
||||
defaultMessage: 'Back',
|
||||
})}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { BackButton, HistoryProvider, useHistory };
|
||||
export type { BackButtonProps, HistoryProviderProps, HistoryContextValue, HistoryState };
|
||||
|
||||
@ -3,7 +3,7 @@ import { useMemo } from 'react';
|
||||
import { render as renderRTL, screen, waitFor } from '@tests/utils';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
|
||||
import { BackButton, HistoryProvider } from '../BackButton';
|
||||
import { BackButton, type BackButtonProps, HistoryProvider } from '../BackButton';
|
||||
|
||||
const LocationDisplay = () => {
|
||||
const location = useLocation();
|
||||
@ -19,8 +19,8 @@ const RandomNavLink = () => {
|
||||
return <NavLink to={to}>Navigate</NavLink>;
|
||||
};
|
||||
|
||||
const render = () =>
|
||||
renderRTL(<BackButton />, {
|
||||
const render = (props: BackButtonProps = {}) =>
|
||||
renderRTL(<BackButton {...props} />, {
|
||||
renderOptions: {
|
||||
wrapper({ children }) {
|
||||
return (
|
||||
@ -35,12 +35,18 @@ const render = () =>
|
||||
});
|
||||
|
||||
describe('BackButton', () => {
|
||||
it('should be disabled if there is no history', () => {
|
||||
it('should be disabled if there is no history and no fallback', () => {
|
||||
render();
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute('aria-disabled', 'true');
|
||||
});
|
||||
|
||||
it('should be enabled if there is a fallback', () => {
|
||||
render({ fallback: '..' });
|
||||
|
||||
expect(screen.getByRole('link', { name: 'Back' })).toHaveAttribute('aria-disabled', 'false');
|
||||
});
|
||||
|
||||
it('should be enabled if there is history', async () => {
|
||||
const { user } = render();
|
||||
|
||||
|
||||
@ -220,7 +220,7 @@ const CreatePage = () => {
|
||||
id: 'Settings.roles.create.description',
|
||||
defaultMessage: 'Define the rights given to the role',
|
||||
})}
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback="../roles" />}
|
||||
/>
|
||||
<Layouts.Content>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
|
||||
@ -211,7 +211,7 @@ const EditPage = () => {
|
||||
id: 'Settings.roles.create.description',
|
||||
defaultMessage: 'Define the rights given to the role',
|
||||
})}
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback="../roles" />}
|
||||
/>
|
||||
<Layouts.Content>
|
||||
<Flex direction="column" alignItems="stretch" gap={6}>
|
||||
|
||||
@ -205,7 +205,7 @@ const EditPage = () => {
|
||||
name: getDisplayName(initialData),
|
||||
}
|
||||
)}
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback="../users" />}
|
||||
/>
|
||||
<Layouts.Content>
|
||||
{user?.registrationToken && (
|
||||
|
||||
@ -125,7 +125,7 @@ const WebhookForm = ({
|
||||
})
|
||||
: data?.name
|
||||
}
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback="../webhooks" />}
|
||||
/>
|
||||
<Layouts.Content>
|
||||
<Flex direction="column" alignItems="stretch" gap={4}>
|
||||
|
||||
@ -19,7 +19,7 @@ import {
|
||||
} from '@strapi/design-system';
|
||||
import { ListPlus, Pencil, Trash, WarningCircle } from '@strapi/icons';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useMatch, useNavigate } from 'react-router-dom';
|
||||
import { useMatch, useNavigate, useParams } from 'react-router-dom';
|
||||
|
||||
import { RelativeTime } from '../../../components/RelativeTime';
|
||||
import {
|
||||
@ -30,7 +30,7 @@ import {
|
||||
UPDATED_AT_ATTRIBUTE_NAME,
|
||||
UPDATED_BY_ATTRIBUTE_NAME,
|
||||
} from '../../../constants/attributes';
|
||||
import { SINGLE_TYPES } from '../../../constants/collections';
|
||||
import { COLLECTION_TYPES, SINGLE_TYPES } from '../../../constants/collections';
|
||||
import { useDocumentRBAC } from '../../../features/DocumentRBAC';
|
||||
import { useDoc } from '../../../hooks/useDocument';
|
||||
import { useDocumentActions } from '../../../hooks/useDocumentActions';
|
||||
@ -55,6 +55,7 @@ interface HeaderProps {
|
||||
const Header = ({ isCreating, status, title: documentTitle = 'Untitled' }: HeaderProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const isCloning = useMatch(CLONE_PATH) !== null;
|
||||
const params = useParams<{ collectionType: string; slug: string }>();
|
||||
|
||||
const title = isCreating
|
||||
? formatMessage({
|
||||
@ -65,7 +66,13 @@ const Header = ({ isCreating, status, title: documentTitle = 'Untitled' }: Heade
|
||||
|
||||
return (
|
||||
<Flex direction="column" alignItems="flex-start" paddingTop={6} paddingBottom={4} gap={2}>
|
||||
<BackButton />
|
||||
<BackButton
|
||||
fallback={
|
||||
params.collectionType === SINGLE_TYPES
|
||||
? undefined
|
||||
: `../${COLLECTION_TYPES}/${params.slug}`
|
||||
}
|
||||
/>
|
||||
<Flex width="100%" justifyContent="space-between" gap="80px" alignItems="flex-start">
|
||||
<Typography variant="alpha" tag="h1">
|
||||
{title}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useForm, BackButton, Layouts } from '@strapi/admin/strapi-admin';
|
||||
import { Button } from '@strapi/design-system';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import { COLLECTION_TYPES } from '../../../constants/collections';
|
||||
import { capitalise } from '../../../utils/strings';
|
||||
import { getTranslation } from '../../../utils/translations';
|
||||
|
||||
@ -13,13 +15,14 @@ interface HeaderProps {
|
||||
|
||||
const Header = ({ name }: HeaderProps) => {
|
||||
const { formatMessage } = useIntl();
|
||||
const params = useParams<{ slug: string }>();
|
||||
|
||||
const modified = useForm('Header', (state) => state.modified);
|
||||
const isSubmitting = useForm('Header', (state) => state.isSubmitting);
|
||||
|
||||
return (
|
||||
<Layouts.Header
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback={`../${COLLECTION_TYPES}/${params.slug}`} />}
|
||||
primaryAction={
|
||||
<Button size="S" disabled={!modified} type="submit" loading={isSubmitting}>
|
||||
{formatMessage({ id: 'global.save', defaultMessage: 'Save' })}
|
||||
|
||||
@ -271,7 +271,7 @@ const ReleaseDetailsLayout = ({
|
||||
<Badge {...getBadgeProps(release.status)}>{release.status}</Badge>
|
||||
</Flex>
|
||||
}
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback=".." />}
|
||||
primaryAction={
|
||||
!release.releasedAt && (
|
||||
<Flex gap={2}>
|
||||
|
||||
@ -347,7 +347,7 @@ const EditPage = () => {
|
||||
{({ modified, isSubmitting, values, setErrors }) => (
|
||||
<>
|
||||
<Layout.Header
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback=".." />}
|
||||
primaryAction={
|
||||
canUpdate || canCreate ? (
|
||||
<Button
|
||||
|
||||
@ -119,7 +119,7 @@ export const EditPage = () => {
|
||||
}
|
||||
title={role.name}
|
||||
subtitle={role.description}
|
||||
navigationAction={<BackButton />}
|
||||
navigationAction={<BackButton fallback=".." />}
|
||||
/>
|
||||
<Layouts.Content>
|
||||
<Flex
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user