Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/boot/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@
### Enhancements

- Add a `stage` visibility function to a route's config, mirroring `inspector`, so a route can hide its stage and leave the canvas to fill the surfaces area.
- Pass the editor's entity navigation callbacks from the canvas, so a template part or synced pattern can be opened from the block inspector and navigated back out of, restoring the block that was selected ([#81590](https://github.com/WordPress/gutenberg/pull/81590)).
- Add `registerEntityLinks` and `getEntityLink`, so an application declares where each post type is listed and edited and the canvas resolves every link through them ([#81590](https://github.com/WordPress/gutenberg/pull/81590)).
- Run the editor's post actions from the canvas, returning to the list when an entity is trashed or deleted and offering a way to reach a duplicate ([#81590](https://github.com/WordPress/gutenberg/pull/81590)).

### Breaking Changes

- Remove `editLink` from a route's canvas data. Register the post type's `edit` path with `registerEntityLinks` instead ([#81590](https://github.com/WordPress/gutenberg/pull/81590)).

## 0.20.0 (2026-08-12)

Expand Down
58 changes: 48 additions & 10 deletions packages/boot/src/components/canvas/index.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { useState, useEffect } from '@wordpress/element';
import { useState, useEffect, useMemo } from '@wordpress/element';
import { Spinner } from '@wordpress/components';
import { useNavigate } from '@wordpress/route';
import { useSelect } from '@wordpress/data';
import { store as bootStore } from '../../store';
import type { CanvasData } from '../../store/types';
import BootBackButton from './back-button';
import useNavigateToEntityRecord, {
useActionPerformed,
} from './use-navigate-to-entity-record';

interface CanvasProps {
canvas: CanvasData;
Expand All @@ -18,6 +23,43 @@ interface CanvasProps {
export default function Canvas( { canvas }: CanvasProps ) {
const [ Editor, setEditor ] = useState< any >( null );
const navigate = useNavigate();
const { onNavigateToEntityRecord, onNavigateToPreviousEntityRecord } =
useNavigateToEntityRecord();
const onActionPerformed = useActionPerformed( canvas.postType );

// Where clicking a previewed canvas goes, resolved the same way the editor
// resolves anywhere else it sends you to edit an entity.
const editLink = useSelect(
( select ) =>
canvas.postType && canvas.postId
? select( bootStore ).getEntityLink(
canvas.postType,
canvas.postId
)
: undefined,
[ canvas.postType, canvas.postId ]
);

/*
* Memoized because the editor provider pushes these settings into the store
* whenever their identity changes, and the callbacks have to stay stable for
* the block inspector to keep offering the same affordance.
*/
const settings = useMemo(
() => ( {
isPreviewMode: canvas.isPreview,
styles: canvas.isPreview
? [ { css: 'body{min-height:100vh;}' } ]
: [],
onNavigateToEntityRecord,
onNavigateToPreviousEntityRecord,
} ),
[
canvas.isPreview,
onNavigateToEntityRecord,
onNavigateToPreviousEntityRecord,
]
);

useEffect( () => {
// Dynamically import the lazy-editor module
Expand Down Expand Up @@ -67,22 +109,18 @@ export default function Canvas( { canvas }: CanvasProps ) {
<Editor
postType={ canvas.postType }
postId={ canvas.postId }
settings={ {
isPreviewMode: canvas.isPreview,
styles: canvas.isPreview
? [ { css: 'body{min-height:100vh;}' } ]
: [],
} }
settings={ settings }
backButton={ backButton }
onActionPerformed={ onActionPerformed }
/>
</div>
{ canvas.isPreview && canvas.editLink && (
{ canvas.isPreview && editLink && (
<div
onClick={ () => navigate( { to: canvas.editLink } ) }
onClick={ () => navigate( { to: editLink } ) }
onKeyDown={ ( e ) => {
if ( e.key === 'Enter' || e.key === ' ' ) {
e.preventDefault();
navigate( { to: canvas.editLink } );
navigate( { to: editLink } );
}
} }
style={ {
Expand Down
200 changes: 200 additions & 0 deletions packages/boot/src/components/canvas/use-navigate-to-entity-record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { useCallback, useMemo } from '@wordpress/element';
import { useDispatch, useRegistry, useSelect } from '@wordpress/data';
import { store as coreStore } from '@wordpress/core-data';
import { store as editorStore } from '@wordpress/editor';
import { store as noticesStore } from '@wordpress/notices';
import { decodeEntities } from '@wordpress/html-entities';
import { __, sprintf } from '@wordpress/i18n';
import {
useNavigate,
useSearch,
privateApis as routePrivateApis,
} from '@wordpress/route';
import { store as bootStore } from '../../store';
import { unlock } from '../../lock-unlock';

const { useCanGoBack, useRouter } = unlock( routePrivateApis );

/*
* The route tree is assembled from routes registered at runtime, so TanStack has
* no search schema to infer and types `search` against a placeholder. Describe
* the two calls this module makes instead.
*/
type Navigate = ( options: {
to?: string;
replace?: boolean;
search?: (
previous: Record< string, unknown >
) => Record< string, unknown >;
} ) => void;

interface NavigateParams {
postType: string;
postId: string;
}

/**
* Builds the editor's entity navigation callbacks.
*
* The editor uses these to move between an entity and one nested inside it —
* a template part or a synced pattern reached from the block inspector. They
* are passed through the editor settings, where `onNavigateToEntityRecord`
* gates the inspector's edit affordance and `onNavigateToPreviousEntityRecord`
* gates the document bar's back button.
*
* @return The two callbacks, for spreading into the editor settings.
*/
export default function useNavigateToEntityRecord() {
const navigate = useNavigate() as Navigate;
const search = useSearch( { strict: false } ) as { focusMode?: unknown };
const registry = useRegistry();
const getEntityLink = useSelect(
( select ) => select( bootStore ).getEntityLink,
[]
);
const router = useRouter();
const canGoBack = useCanGoBack();

const onNavigateToEntityRecord = useCallback(
( params: NavigateParams ) => {
/*
* Stash the block selected in the entity being left, so returning to
* it can restore the user's place. The edits hold the external
* client ID, which `onChangeSelection` has already resolved.
*/
const currentPostType = (
registry.select( editorStore ) as any
).getCurrentPostType();
const currentPostId = (
registry.select( editorStore ) as any
).getCurrentPostId();
const edits = registry
.select( coreStore )
.getEntityRecordEdits(
'postType',
currentPostType,
currentPostId
) as { selection?: { selectionStart?: { clientId?: string } } };
const selectedBlock = edits?.selection?.selectionStart?.clientId;

if ( selectedBlock ) {
navigate( {
search: ( previous: Record< string, unknown > ) => ( {
...previous,
selectedBlock,
} ),
replace: true,
} );
}

/*
* `focusMode` marks the entity as one navigated into rather than
* opened directly, which is what offers the way back out. The search
* is replaced rather than extended so the block stashed above stays
* with the entity it belongs to.
*/
const to = getEntityLink( params.postType, params.postId );

if ( to ) {
navigate( { to, search: () => ( { focusMode: true } ) } );
}
},
[ navigate, registry, getEntityLink ]
);

/*
* Only entities navigated into offer a way back out. The editor reads this
* as the entity being a focused one, giving it the document bar's back
* button and the surrounding padding, so an entity opened directly — from a
* list, say — must not have it. `canGoBack` covers reloading such a URL,
* where the marker survives but the history to return to does not.
*/
const isFocusMode = !! search.focusMode;
const onNavigateToPreviousEntityRecord = useMemo(
() =>
isFocusMode && canGoBack ? () => router.history.back() : undefined,
[ isFocusMode, canGoBack, router ]
);

return { onNavigateToEntityRecord, onNavigateToPreviousEntityRecord };
}

interface ActionItem {
id: string | number;
type: string;
title?: string | { rendered?: string };
}

/**
* Builds the callback the editor runs after a post action.
*
* Actions taken from inside the editor act on the entity being edited, so
* trashing or deleting one leaves the canvas showing something that is no
* longer there, and duplicating one gives no way to reach the copy.
*
* @param postType Post type rendered in the canvas.
* @return The callback, for passing to the editor.
*/
export function useActionPerformed( postType?: string ) {
const navigate = useNavigate() as Navigate;
const { createSuccessNotice } = useDispatch( noticesStore );
const getEntityLink = useSelect(
( select ) => select( bootStore ).getEntityLink,
[]
);

return useCallback(
( actionId: string, items: ActionItem[] ) => {
switch ( actionId ) {
case 'move-to-trash':
case 'delete-post': {
const to = postType && getEntityLink( postType );

if ( to ) {
navigate( { to, search: () => ( {} ) } );
}
break;
}

case 'duplicate-post': {
const newItem = items[ 0 ];
const to = getEntityLink( newItem.type, newItem.id );

if ( ! to ) {
break;
}

const title =
typeof newItem.title === 'string'
? newItem.title
: newItem.title?.rendered;

/*
* The action has already announced the copy under this id.
* Reissuing it under the same id replaces that notice rather
* than stacking a second one, and adds the way to reach it.
*/
createSuccessNotice(
sprintf(
// translators: %s: Title of the created post, e.g: "Hello world".
__( '"%s" successfully created.' ),
decodeEntities( title ?? '' ) || __( '(no title)' )
),
{
type: 'snackbar',
id: 'duplicate-post-action',
actions: [
{
label: __( 'Edit' ),
onClick: () => navigate( { to } ),
},
],
}
);
break;
}
}
},
[ navigate, createSuccessNotice, getEntityLink, postType ]
);
}
19 changes: 18 additions & 1 deletion packages/boot/src/store/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { MenuItem, Route } from './types';
import type { EntityLinks, MenuItem, Route } from './types';

export function registerMenuItem( id: string, menuItem: MenuItem ) {
return {
Expand All @@ -23,6 +23,22 @@ export function registerRoute( route: Route ) {
};
}

/**
* Registers where entities of a post type are listed and edited.
*
* Register `default` to cover post types with no entry of their own.
*
* @param postType Post type the links belong to, or `default`.
* @param links Paths to list and edit an entity of that post type.
*/
export function registerEntityLinks( postType: string, links: EntityLinks ) {
return {
type: 'REGISTER_ENTITY_LINKS' as const,
postType,
links,
};
}

export function setDashboardLink( dashboardLink: string ) {
return {
type: 'SET_DASHBOARD_LINK' as const,
Expand All @@ -34,4 +50,5 @@ export type Action =
| ReturnType< typeof registerMenuItem >
| ReturnType< typeof updateMenuItem >
| ReturnType< typeof registerRoute >
| ReturnType< typeof registerEntityLinks >
| ReturnType< typeof setDashboardLink >;
13 changes: 13 additions & 0 deletions packages/boot/src/store/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const initialState: State = {
menuItems: {},
routes: [],
dashboardLink: undefined,
entityLinks: {},
};

export function reducer( state: State = initialState, action: Action ): State {
Expand Down Expand Up @@ -42,6 +43,18 @@ export function reducer( state: State = initialState, action: Action ): State {
routes: [ ...state.routes, action.route ],
};

case 'REGISTER_ENTITY_LINKS':
return {
...state,
entityLinks: {
...state.entityLinks,
[ action.postType ]: {
...state.entityLinks[ action.postType ],
...action.links,
},
},
};

case 'SET_DASHBOARD_LINK':
return {
...state,
Expand Down
28 changes: 28 additions & 0 deletions packages/boot/src/store/selectors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,31 @@ export function getRoutes( state: State ) {
export function getDashboardLink( state: State ) {
return state.dashboardLink;
}

/**
* Returns the path an entity is edited or listed at.
*
* Falls back to the links registered for `default`, so a post type with no
* entry of its own still resolves.
*
* @param state Store state.
* @param postType Post type to resolve for.
* @param postId Entity to edit. Omit for the list of the post type.
* @return The path, or undefined when nothing is registered for it.
*/
export function getEntityLink(
state: State,
postType: string,
postId?: string | number
) {
const links = state.entityLinks[ postType ] ?? state.entityLinks.default;
const path = postId === undefined ? links?.list : links?.edit;

if ( ! path ) {
return undefined;
}

return path
.replace( '{type}', encodeURIComponent( postType ) )
.replace( '{id}', encodeURIComponent( postId ?? '' ) );
}
Loading
Loading