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
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ The fix is to read the string when it is used rather than when the file
loads.

```tsx
export const mediaNavItem = {
export const mediaMenuItem = {
get label() {
return _x('Media', 'admin section', 'gophenberg')
},
Expand Down
89 changes: 82 additions & 7 deletions docs/src/content/docs/extending/the-plugin-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -461,10 +461,12 @@ export const plugin = {
```

`make generate` wires it in: routes mount inside the admin layout
and nav rows appear after the built-in ones. No built-in plugin
uses this path yet, so expect to be the first through it.
and the rows of `nav` appear in the sidebar after the built-in ones.
Each row is a `MenuItem`: the `label` it shows, the address `to`, and
an optional `icon` and `capability`. No built-in plugin uses this path
yet, so expect to be the first through it.

A nav row naming a `capability` shows only to an account whose role
A row naming a `capability` shows only to an account whose role
carries it. The screen still opens from a pasted link, so the Go
routes your plugin serves must check `session.Can` themselves, as
[the signed-in account](#the-signed-in-account) shows. In the
Expand All @@ -479,19 +481,92 @@ const session = useSession().data
const manages = can(session, 'hello.manage')
```

A row can open a sub menu of its own, as the Site Editor's Navigation
row opens the list of menus. Give the route a `staticData.Sidebar`
component that draws a `NavigationScreen`. The admin swaps the main
menu for it, slides it in, and puts Back at its top, which leads to
the list the admin opens on. Put its rows in a `NavigationList`, each
one a `NavigationItem`. A sub menu on a parent route stays while the
reader moves between the screens under it:

```tsx
import { NavigationItem, NavigationList, NavigationScreen } from '@gophenberg/frontend-sdk'
import { createRoute } from '@tanstack/react-router'
import type { AnyRoute } from '@tanstack/react-router'

function HelloSidebar() {
return (
<NavigationScreen title="Hello">
<NavigationList>
<NavigationItem to="/hello/drafts">Drafts</NavigationItem>
<NavigationItem to="/hello/archive">Archive</NavigationItem>
</NavigationList>
</NavigationScreen>
)
}

export const plugin = {
id: 'hello',
routes: (parent: AnyRoute) => [
createRoute({
getParentRoute: () => parent,
path: '/hello',
staticData: { Sidebar: HelloSidebar },
}),
],
nav: [{ label: 'Hello', to: '/hello' }],
}
```

Leave out the screen's `back`, so Back goes where the admin's own sub
menus go. `NavigationList` also takes `items`, rows as `MenuItem`
data, and hides a row whose `capability` the reader lacks. A
`NavigationItem` hides itself when its address opens a route the
reader may not open. `DrilldownItem` draws a row that opens a further
sub menu, with a chevron, and `MenuCount` draws a number at the end of
a row, such as the updates waiting.

`renderPluginAt` from `@gophenberg/frontend-sdk/testing` mounts your
plugin inside the admin's real sidebar, with Back, the focus moves and
the phone drawer. Pass `session` to seed the account a test reads, or
`null` for none. A row that names a `capability` shows only when that
account carries it. Leave `session` out and the test asks the server
for the account at `/api/auth/session`, so serve that address with
`http` and `server` from the same entry.

**To move** a plugin written for Gophenberg v0.19.0 or earlier:

1. Replace the type `NavItem` with `MenuItem` from
`@gophenberg/frontend-sdk`. `NavItem` is gone, with no alias.
2. If a route declares `staticData.Sidebar`, draw it as a
`NavigationScreen` with its rows in a `NavigationList`, so it gets
its title, Back and the focus the admin moves to it.
3. In a test through `renderPluginAt`, seed a session that carries
the capability a row names, or serve the session address. The test
host now hides a row the reader cannot open, and asks the server
for the session when the test seeds none.

The frontend SDK, `@gophenberg/frontend-sdk`, passes on a few icons
from `@wordpress/icons`, each named after the job it does in the
admin: `authorIcon`, `backIcon`, `backupIcon`, `closeIcon`,
`downIcon`, `listViewIcon`, `mediaIcon`, `postIcon`, `redoIcon`,
`saveIcon`, `sidebarIcon`, `sidebarRtlIcon`, `trashIcon`,
`undoIcon`, `upIcon` and `uploadIcon`. `authorIcon` is the one an
admin: `authorIcon`, `backIcon`, `backupIcon`, `brushIcon`,
`categoryIcon`, `closeIcon`, `customPostTypeIcon`, `downIcon`,
`languageIcon`, `listIcon`, `listViewIcon`, `mediaIcon`, `pageIcon`,
`postIcon`, `redoIcon`, `saveIcon`, `settingsIcon`, `sidebarIcon`,
`sidebarRtlIcon`, `trashIcon`, `undoIcon`, `upIcon` and
`uploadIcon`. `authorIcon` is the one an
author cell shows when the site shows no avatars, `postIcon` the one
an empty content list shows, `mediaIcon` the one an empty media
library shows, and `uploadIcon` the one an upload button carries.
`sidebarIcon` is the one the editor's Settings button carries, and
`sidebarRtlIcon` its mirror for a language written right to left.
`saveIcon` is the one Save draft shows on a phone, and `closeIcon`
the one a panel's close button carries.
The sidebar rows carry the icons WordPress draws for the same rows:
`postIcon` for Posts and every type a site adds, `pageIcon` for
Pages, `categoryIcon` for Categories, `mediaIcon` for Media,
`brushIcon` for Themes, `customPostTypeIcon` for Content Types,
`listIcon` for Field Groups, `languageIcon` for Language and
`settingsIcon` for Settings.
Import them from the SDK, so the admin keeps one copy of the icon set:

```ts
Expand Down
7 changes: 4 additions & 3 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@
},
"dependencies": {
"@gophenberg/frontend-sdk": "workspace:*",
"@gopherium/godmin": "0.23.1",
"@gopherium/godmin": "0.25.0",
"@gopherium/gottext": "0.7.0",
"@gopherium/react-auth": "0.13.0",
"@gopherium/react-auth": "0.15.0",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-router": "^1.170.36",
"@tanstack/react-router": "^1.170.44",
"@wordpress/date": "5.57.0",
"@wordpress/i18n": "6.30.0",
"@wordpress/icons": "18.0.0",
"@wordpress/theme": "2.3.0",
"@wordpress/ui": "0.24.0",
"react": "^19.2.8",
Expand Down
40 changes: 31 additions & 9 deletions frontend/src/Layout.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,48 @@
// SPDX-License-Identifier: Apache-2.0

import { Text } from '@gophenberg/frontend-sdk'
import { Text, can } from '@gophenberg/frontend-sdk'
import { Frame } from '@gopherium/godmin'
import { __ } from '@wordpress/i18n'
import { useCanvas, useFrameLocation } from '@gopherium/godmin/router'
import { Outlet } from '@tanstack/react-router'
import { Navigation, useCanvas, useFrameLocation } from '@gopherium/godmin/router'
import { useSession } from '@gopherium/react-auth'
import { AccountPanel } from '@gopherium/react-auth/wpds'
import { Link, Outlet } from '@tanstack/react-router'
import { __, sprintf } from '@wordpress/i18n'

import { RailContent } from './RailContent'
import { useContentNav, useDefaultType } from './content/nav'
import { coreNav } from './menu/coreNav'
import { plugins } from './plugins'
import { useAppVersion } from './version'

/**
* Returns the link to the list the root lands on, where Back on a sub menu goes.
* @returns The default type's list once the types answer, else the root, which waits on them or reports them.
*/
function useHome() {
const { key } = useDefaultType()
return key === undefined ? <Link to="/" /> : <Link to="/content/$typeKey" params={{ typeKey: key }} />
}

/**
* Renders the admin layout framing the active route.
* @returns The layout element.
*/
export function Layout() {
const session = useSession().data
const version = useAppVersion().data
return (
<Frame.Root location={useFrameLocation()}>
<Frame.Rail
<Frame.Sidebar
brand={<Text variant="heading-lg">{'Gophenberg'}</Text>}
menuLabel={__('Open navigation', 'gophenberg')}
footer={<AccountPanel />}
footerNote={version ? sprintf(__('v%(version)s', 'gophenberg'), { version }) : undefined}
>
<RailContent />
</Frame.Rail>
<Navigation
title={'Gophenberg'}
items={[...useContentNav(), ...coreNav, ...plugins.flatMap((plugin) => plugin.nav)]}
home={useHome()}
can={(capability) => can(session, capability)}
/>
</Frame.Sidebar>
<Frame.Canvas canvas={useCanvas()}>
<Outlet />
</Frame.Canvas>
Expand Down
38 changes: 0 additions & 38 deletions frontend/src/RailContent.tsx

This file was deleted.

62 changes: 26 additions & 36 deletions frontend/src/content/GroupsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,69 +137,58 @@ export interface Shadowing {
*/
export function shadowings(groups: PlacedGroup[], types: NamedType[]): Shadowing[] {
const active = groups.filter((held) => held.active)
const found: Shadowing[] = []
const noted = new Map<string, Shadowing>()
for (const listed of types) {
collectOverlaps(active, listed, noted, found)
const reaching = active.filter((held) => matchesType(held.location, listed.key))
for (const overlap of overlapsAmong(reaching)) {
noteOverlap(noted, overlap, pluralOf(listed))
}
}
return found
return [...noted.values()]
}

/** One field key two groups on the same type both hold. */
interface Overlap {
key: string
winner: PlacedGroup
loser: PlacedGroup
}

/**
* Adds every overlap one type carries to the overlaps already found.
* @param active - The active groups in listing order.
* @param listed - The type walked.
* @param noted - The overlaps already found, by key and pair.
* @param found - The overlaps in the order they were found.
* Returns every field key a later group holds while an earlier one serves it.
* @param reaching - The active groups that reach one type, in listing order.
* @returns The overlaps in the order they were found.
*/
function collectOverlaps(
active: PlacedGroup[],
listed: NamedType,
noted: Map<string, Shadowing>,
found: Shadowing[],
) {
function overlapsAmong(reaching: PlacedGroup[]): Overlap[] {
const winners = new Map<string, PlacedGroup>()
for (const held of active) {
if (!matchesType(held.location, listed.key)) {
continue
}
const overlaps: Overlap[] = []
for (const held of reaching) {
for (const f of held.fields) {
const winner = winners.get(f.key)
if (winner === undefined) {
winners.set(f.key, held)
continue
} else {
overlaps.push({ key: f.key, winner, loser: held })
}
noteOverlap(noted, found, f.key, winner, held, pluralOf(listed))
}
}
return overlaps
}

/**
* Records the overlap between two groups on one type.
* @param noted - The overlaps already found, by key and pair.
* @param found - The overlaps in the order they were found.
* @param key - The field key both groups hold.
* @param winner - The group serving the key.
* @param loser - The group holding the key without showing it.
* @param noted - The overlaps already found, by key and pair, in the order they were found.
* @param overlap - The field key and the two groups holding it.
* @param label - The plural label of the type both reach.
*/
function noteOverlap(
noted: Map<string, Shadowing>,
found: Shadowing[],
key: string,
winner: PlacedGroup,
loser: PlacedGroup,
label: string,
) {
function noteOverlap(noted: Map<string, Shadowing>, { key, winner, loser }: Overlap, label: string) {
const at = `${key}:${winner.id}:${loser.id}`
const known = noted.get(at)
if (known !== undefined) {
known.where.push(label)
return
}
const fresh = { key, winner: winner.title, loser: loser.title, loserID: loser.id, where: [label] }
noted.set(at, fresh)
found.push(fresh)
noted.set(at, { key, winner: winner.title, loser: loser.title, loserID: loser.id, where: [label] })
}

/**
Expand Down Expand Up @@ -261,6 +250,7 @@ export function GroupsScreen() {
return (
<Page
title={__('Field Groups', 'gophenberg')}
headingLevel={2}
subtitle={__('Bundles of fields, shown where their rules place them.', 'gophenberg')}
actions={
<>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/content/PostsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ export function PostsScreen() {
return (
<Page
title={pluralOf(listed)}
headingLevel={2}
subtitle={waiting ? undefined : subtitleOf(listed)}
actions={
<>
Expand Down
5 changes: 3 additions & 2 deletions frontend/src/content/TypesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useFocusOnList } from '../lists/focusOnList'
import type { ListSettings } from '../settings/api'
import { useListSettings } from '../settings/useListSettings'
import { typesNavItem, typesQueryKey } from './nav'
import { typesMenuItem, typesQueryKey } from './nav'
import { TYPE_COLUMNS, TYPE_PHONE_COLUMNS, TYPE_SORT, TYPE_VIEW_GUARD, originsOf, typeFields } from './typeFields'
import { useTypeActions } from './typeActions'
import { AddTypeDialog } from './TypeModals'
Expand Down Expand Up @@ -100,7 +100,7 @@ function TypesTable(props: {
data={shown.data}
paginationInfo={shown.paginationInfo}
config={{ perPageSizes: props.settings?.list_page_sizes ?? [] }}
empty={<ListEmpty icon={typesNavItem.icon} title={__('No content types found.', 'gophenberg')} />}
empty={<ListEmpty icon={typesMenuItem.icon} title={__('No content types found.', 'gophenberg')} />}
fields={fields}
actions={props.actions}
view={view}
Expand Down Expand Up @@ -147,6 +147,7 @@ export function TypesScreen() {
return (
<Page
title={__('Content Types', 'gophenberg')}
headingLevel={2}
subtitle={__('Every kind of content this site holds.', 'gophenberg')}
actions={<AddType />}
list
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/content/fields.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Badge } from '@gophenberg/frontend-sdk'
import type { Field, Filter } from '@gophenberg/frontend-sdk/dataviews'
import { HUGE_BREAKPOINT } from '@gopherium/godmin'
import type { ViewGuard } from '@gopherium/godmin'
import { listDate } from '@gopherium/godmin/dates'
import { formatDate } from '@gopherium/gottext'
Expand Down Expand Up @@ -143,7 +144,7 @@ export const PHONE_COLUMNS = ['status', 'date']
export const TABLE_LAYOUT = { layout: { styles: { author: { align: 'start', maxWidth: '15ch' } } } } as const

/** The viewports below WordPress's huge breakpoint, where a table opens on its built-in columns alone. */
export const BELOW_HUGE_QUERY = '(max-width: 1439px)'
export const BELOW_HUGE_QUERY = `(max-width: ${HUGE_BREAKPOINT - 1}px)`

/**
* Returns the ids of the columns a list shows before a reader picks any, the fields a type marks for the list last.
Expand Down
Loading
Loading