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
24 changes: 17 additions & 7 deletions docs/src/content/docs/extending/the-plugin-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ covers `Needs` in full.

A capability is one named permission, such as `notes.manage`. A role
is a set of capabilities an account holds. The core declares four
roles, `admin`, `editor`, `author` and `member`, and six
roles, `admin`, `editor`, `author` and `member`, and seven
capabilities. A plugin adds its own through `RoleProvider`, which
returns them as data:

Expand Down Expand Up @@ -463,8 +463,14 @@ export const plugin = {
`make generate` wires it in: routes mount inside the admin layout
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.
an optional `icon`, `capability` and `count`. No built-in plugin uses
this path yet, so expect to be the first through it.

A `count` is `{ value, label }`: the number drawn after the label,
hidden at zero, and the translated sentence a screen reader reads for
it, such as `1 update available`. The core's Updates row counts the
update that waits this way. A plugin's `nav` is plain data, so a count
there stays the same while the admin is open.

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
Expand All @@ -481,6 +487,10 @@ const session = useSession().data
const manages = can(session, 'hello.manage')
```

The SDK names the core's capabilities as constants, such as
`MANAGE_USERS` and `UPDATE_CORE`, so `can(session, UPDATE_CORE)`
asks whether the account may update Gophenberg.

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
Expand Down Expand Up @@ -553,8 +563,8 @@ admin: `audioIcon`, `authorIcon`, `backIcon`, `backupIcon`,
`downIcon`, `fileIcon`, `imageIcon`, `languageIcon`, `listIcon`,
`listViewIcon`, `mediaIcon`, `pageIcon`, `postIcon`, `redoIcon`,
`saveIcon`, `settingsIcon`, `sidebarIcon`, `sidebarRtlIcon`,
`trashIcon`, `undoIcon`, `upIcon`, `uploadIcon` and `videoIcon`.
`authorIcon` is the one an
`trashIcon`, `undoIcon`, `upIcon`, `updateIcon`, `uploadIcon` and
`videoIcon`. `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.
Expand All @@ -569,8 +579,8 @@ 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.
`listIcon` for Field Groups, `languageIcon` for Language,
`settingsIcon` for Settings and `updateIcon` for Updates.
Import them from the SDK, so the admin keeps one copy of the icon set:

```ts
Expand Down
75 changes: 75 additions & 0 deletions docs/src/content/docs/self-hosting/updates-and-backups.md
Original file line number Diff line number Diff line change
Expand Up @@ -695,6 +695,81 @@ its start check, it goes back on its own, as
shows, and the admin can try that version again once you have fixed
what failed.

### The Updates screen

**Updates** is the last item of the admin menu. It shows to every role
carrying `update_core`, which `admin` carries. While a newer version
waits, a small number sits beside **Updates**, and a line above every
other admin screen names the version and links to the screen. After an
update fails before it switches builds, a line says the update to that
version failed to complete and links to the screen to try again. When
the failed version is the one on offer, only that line shows.

From top to bottom, the screen shows:

- The version that runs, under the title.
- When the site last checked for a new version, and when the next
check runs. **Check again** checks now. A second check within
`GOPHENBERG_UPDATES_SCREEN_CHECK_AGE`, one minute unless set, is
refused, and the screen asks you to try again later. When
`GOPHENBERG_UPDATE_CHECKS` is `false`, the screen says the site does
not check on its own.
- That the site receives no automatic updates. Nothing installs until
you press the button.
- The version on offer and a link to its release notes, or that the
site runs the latest version. Before the first good check it says
neither, as it cannot know yet. There is no button to install the
same version again, since the server refuses a version that is not
newer than the one that runs.
- How many database backups the install root keeps, and when the
newest was taken.

**Update to version** asks before it starts, and says what happens if
the new version does not start. Once you confirm, the warning to back
up and the sentence above the button go away, and the screen shows each
step on its own line, the one under way marked: the download, the
unpacking, the check of the unpacked files, the database backup, the
restart and the check of the new version. It asks the server where the
update stands every `GOPHENBERG_UPDATES_SCREEN_PROGRESS_INTERVAL`, 2
seconds unless set. While the server restarts it cannot answer, so the
screen keeps asking for up to `GOPHENBERG_UPDATES_SCREEN_RESTART_WAIT`,
5 minutes unless set. Opening the screen while an update runs shows its
steps and its ending too.

The update ends in one of five ways:

- **Gophenberg updated successfully.** The open tab still runs the old
admin, so press **Reload the admin**.
- **The update to 1.4.1 failed.** The message adds that Gophenberg went
back to 1.4.0 and is running normally, since the new version failed
its start check. It stays until you dismiss it. The version stays on
offer, and under the line saying it failed its start check, the
button now reads **Re-install version 1.4.1**. It tries that version
again, as `-retry` does, so fix what failed first.
- **The update to 1.4.1 failed its start check, and Gophenberg cannot
go back to 1.4.0 on its own.** An update that raised the minimum
version failed its start check, and the screen says so once the
service manager starts the new version again. Run `update:status` to
see the backup taken before the update, then restore it or fix what
failed, see
[an update that raised the minimum version](#an-update-that-raised-the-minimum-version).
- **The update failed before anything changed.** A step before the
switch failed, and the reason follows. The site keeps running the
version it ran, and **Update to version** tries again.
- **Gophenberg has not come back yet.** The wait passed with no answer
from the new version. Check that your service manager starts
Gophenberg again after exit code 75, and run `update:status` on the
server to see where the update stands. The screen keeps asking, and
shows how the update ended once Gophenberg answers again.

Two offers show no button. An update that needs a newer PostgreSQL
names the version it needs. An install that updates by hand, such as
the Docker image or a binary you built yourself, says why and links to
this page. An update that raised the minimum version keeps its button,
and says the backup is required and that the site stays down if the
new version does not start, see
[an update that raised the minimum version](#an-update-that-raised-the-minimum-version).

### The boot guard

At every start, before it opens the database, Gophenberg reads
Expand Down
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,13 @@
},
"dependencies": {
"@gophenberg/frontend-sdk": "workspace:*",
"@gopherium/godmin": "0.25.0",
"@gopherium/godmin": "0.26.0",
"@gopherium/gottext": "0.7.0",
"@gopherium/react-auth": "0.15.0",
"@tanstack/react-query": "^5.102.8",
"@tanstack/react-router": "^1.170.44",
"@wordpress/date": "5.57.0",
"@wordpress/element": "9.0.0",
"@wordpress/i18n": "6.30.0",
"@wordpress/icons": "18.0.0",
"@wordpress/theme": "2.3.0",
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { __, sprintf } from '@wordpress/i18n'
import { useContentNav, useDefaultType } from './content/nav'
import { coreNav } from './menu/coreNav'
import { plugins } from './plugins'
import { useUpdatesMenuItem } from './updates/nav'
import { UpdateNag } from './updates/UpdateNag'
import { useAppVersion } from './version'

/**
Expand Down Expand Up @@ -38,12 +40,12 @@ export function Layout() {
>
<Navigation
title={'Gophenberg'}
items={[...useContentNav(), ...coreNav, ...plugins.flatMap((plugin) => plugin.nav)]}
items={[...useContentNav(), ...coreNav, useUpdatesMenuItem(), ...plugins.flatMap((plugin) => plugin.nav)]}
home={useHome()}
can={(capability) => can(session, capability)}
/>
</Frame.Sidebar>
<Frame.Canvas canvas={useCanvas()}>
<Frame.Canvas canvas={useCanvas()} notices={<UpdateNag />}>
<Outlet />
</Frame.Canvas>
</Frame.Root>
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,51 @@
}
}

.gophenberg-updates__check {
padding: 0;
border: 0;
background: none;
font: inherit;
text-decoration: underline;
cursor: pointer;
}

.gophenberg-updates__check:disabled {
cursor: default;
}

.gophenberg-updates__last-check {
text-wrap: wrap;
}

.gophenberg-updates__version {
white-space: nowrap;
}

.gophenberg-updates__steps {
display: flex;
flex-direction: column;
gap: var(--wpds-dimension-gap-sm);
margin: 0;
padding: 0;
list-style: none;
}

.gophenberg-updates__steps:focus {
outline: none;
}

.gophenberg-updates__steps > li {
display: flex;
align-items: center;
gap: var(--wpds-dimension-gap-sm);
color: var(--wpds-color-foreground-content-neutral-weak);
}

.gophenberg-updates__steps > li[aria-current='step'] {
color: var(--wpds-color-foreground-content-neutral);
}

.gophenberg-no-access {
box-sizing: border-box;
max-width: min(var(--wpds-dimension-surface-width-lg), calc(100% - 2 * var(--wpds-dimension-padding-lg)));
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/languages/es-ES.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion frontend/src/languages/fr-FR.json

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion frontend/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
import type { RouterHistory } from '@tanstack/react-router'
import { __ } from '@wordpress/i18n'

import { MANAGE_SETTINGS, MANAGE_THEMES, MANAGE_TYPES, MANAGE_USERS } from '@gophenberg/frontend-sdk'
import { MANAGE_SETTINGS, MANAGE_THEMES, MANAGE_TYPES, MANAGE_USERS, UPDATE_CORE } from '@gophenberg/frontend-sdk'

import { adminBasepath } from './basepath'
import { CapabilityGate } from './CapabilityGate'
Expand Down Expand Up @@ -123,6 +123,13 @@ const settingsRoute = createRoute({
component: lazyRouteComponent(() => import('./settings/SettingsScreen'), 'SettingsScreen'),
})

const updatesRoute = createRoute({
getParentRoute: () => adminRoute,
path: '/update-core',
staticData: { capability: UPDATE_CORE },
component: lazyRouteComponent(() => import('./updates/UpdatesScreen'), 'UpdatesScreen'),
})

const editorRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/content/$typeKey/$postId/edit',
Expand All @@ -142,6 +149,7 @@ const routeTree = rootRoute.addChildren([
newUserRoute,
themesRoute,
settingsRoute,
updatesRoute,
]),
...plugins.flatMap((plugin) => plugin.routes(framedRoute)),
]),
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/settings/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ const listSettingsSchema = z.object({
toast_milliseconds: z.number(),
toast_name_length: z.number(),
format_locale: z.string(),
updates_progress_milliseconds: z.number(),
updates_restart_wait_milliseconds: z.number(),
})

const errorSchema = z.object({
Expand All @@ -31,7 +33,7 @@ export type SiteSettings = z.infer<typeof settingsSchema>
/** The settings a request asks the site to store. */
export type SettingsAsked = Partial<SiteSettings>

/** The settings every list follows, as the server's environment names them. */
/** The settings every list and the Updates screen follow, as the server's environment names them. */
export type ListSettings = z.infer<typeof listSettingsSchema>

/**
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/test/classes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// SPDX-License-Identifier: Apache-2.0

import { render } from '@testing-library/react'
import type { ReactElement } from 'react'

/**
* Returns the classes WPDS draws an element with when it renders on its own.
* @param element - The element to render.
* @returns The class attribute of the rendered element.
*/
export function classesOf(element: ReactElement): string {
const { container, unmount } = render(element)
const classes = (container.firstElementChild as HTMLElement).className
unmount()
return classes
}
3 changes: 3 additions & 0 deletions frontend/src/test/navigation-items.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
pageIcon,
postIcon,
settingsIcon,
updateIcon,
} from '@gophenberg/frontend-sdk'
import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing'
import { render, screen, within } from '@testing-library/react'
Expand Down Expand Up @@ -75,6 +76,7 @@ test('lists every sidebar row as an item of one list, the types first', async ()
'Users',
'Language',
'Settings',
'Updates',
])
})

Expand All @@ -97,6 +99,7 @@ test('draws each sidebar row with the glyph WordPress draws for it, other types
['Users', people],
['Language', languageIcon],
['Settings', settingsIcon],
['Updates', updateIcon],
]
for (const [name, glyph] of glyphs) {
expect(drawingOf(sidebar.getByRole('link', { name })), name).toBe(drawingOf(glyph))
Expand Down
1 change: 1 addition & 0 deletions frontend/src/test/outline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const TITLES: Record<string, string> = {
'/themes': 'Themes',
'/language': 'Language',
'/settings': 'Settings',
'/update-core': 'Gophenberg Updates',
}

const FRAMED = Object.keys(TITLES)
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/test/render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
MANAGE_THEMES,
MANAGE_TYPES,
MANAGE_USERS,
UPDATE_CORE,
} from '@gophenberg/frontend-sdk'
import { createAuthQueryClient } from '@gopherium/react-auth'
import type { User } from '@gopherium/react-auth'
Expand All @@ -29,6 +30,7 @@ export const adminCapabilities = [
MANAGE_TYPES,
MANAGE_SETTINGS,
CHANGE_OTHERS_WORK,
UPDATE_CORE,
]

/** The account tests render as, an administrator unless a test names another role. */
Expand Down
28 changes: 26 additions & 2 deletions frontend/src/test/role-navigation.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: Apache-2.0

import { CHANGE_OTHERS_WORK, MANAGE_THEMES } from '@gophenberg/frontend-sdk'
import { CHANGE_OTHERS_WORK, MANAGE_THEMES, UPDATE_CORE } from '@gophenberg/frontend-sdk'
import { http, HttpResponse, server } from '@gophenberg/frontend-sdk/testing'
import { defaultUser } from '@gopherium/react-auth/testing'
import { screen, waitFor } from '@testing-library/react'
Expand All @@ -12,7 +12,7 @@ import { warmPostsScreen } from './warm'
warmPostsScreen()

/** The gated screens, each behind the capability it asks for. */
const GATED = ['Users', 'Themes', 'Content Types', 'Settings']
const GATED = ['Users', 'Themes', 'Content Types', 'Settings', 'Updates']

/** The screens every signed in role reaches. */
const OPEN = ['Media', 'Language']
Expand Down Expand Up @@ -120,3 +120,27 @@ test('leaves an administrator on the gated screen it asked for', async () => {

expect(await screen.findByRole('heading', { name: 'Users' })).toBeInTheDocument()
})

test('shows a role carrying only the update capability the Updates screen alone', async () => {
renderAt('/', holding('maintainer', [UPDATE_CORE]))

await screen.findByRole('link', { name: 'Media' })

expect(screen.getByRole('link', { name: 'Updates' })).toHaveAttribute('href', '/admin/update-core')
for (const label of GATED.filter((gated) => gated !== 'Updates')) {
expect(screen.queryByRole('link', { name: label })).toBeNull()
}
})

test('leaves a role carrying the update capability on the Updates screen it asked for', async () => {
renderAt('/update-core', holding('maintainer', [UPDATE_CORE]))

expect(await screen.findByRole('heading', { level: 2, name: 'Gophenberg Updates' })).toBeInTheDocument()
})

test('answers a deep link into the Updates screen without update_core by opening the default list', async () => {
renderAt('/update-core', holding('editor', [CHANGE_OTHERS_WORK]))

expect(await screen.findByRole('heading', { name: 'Posts', level: 2 })).toBeInTheDocument()
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Gophenberg Updates' })).toBeNull())
})
Loading
Loading