From 64e21e9f944a0a1e8862734c234c408aa8aec9bb Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 14 Jul 2026 16:45:27 +0200 Subject: [PATCH] Offer creating the page on the not-found screen (#115) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following a phantom wikilink now ends with a way out instead of a dead end: when the pond resolved and the page 404s as plain not_found, the error screen offers creating the page in place. Title = the URL slug (the PhantomPagesView mechanic), so every wikilink pointing at the address resolves; the invalidated page query then mounts the editor on the same URL. The affordance is deliberately ungated like the sidebar's new-page button — the client cannot tell 'never existed' from 'not readable' (#60), and a reader's POST surfaces as the regular 403 banner. The page_trashed branch (#31) is untouched. Rides along: PhantomPagesView now also invalidates ['pond-links'] — the graph views kept showing a just-created target as a phantom. e2e pack create-missing-page.spec.ts (CI wiring lands with #119): author a phantom link, follow it, create, backlink proves resolution; reader path asserts the 403 banner and no editor mount. Co-Authored-By: Claude Fable 5 --- apps/web/e2e/create-missing-page.spec.ts | 89 ++++++++++++++++++++++++ apps/web/src/links/PhantomPagesView.tsx | 3 + apps/web/src/pages/PageEditorPage.tsx | 65 ++++++++++++++++- apps/web/src/styles/base.css | 11 +++ packages/shared/i18n/de/editor.json | 4 ++ packages/shared/i18n/en/editor.json | 4 ++ 6 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 apps/web/e2e/create-missing-page.spec.ts diff --git a/apps/web/e2e/create-missing-page.spec.ts b/apps/web/e2e/create-missing-page.spec.ts new file mode 100644 index 0000000..1b030b4 --- /dev/null +++ b/apps/web/e2e/create-missing-page.spec.ts @@ -0,0 +1,89 @@ +import { expect, test } from '@playwright/test'; + +import { contextForUser } from './helpers'; + +/** + * Create-from-not-found pack (issue #115): following a phantom wikilink dead- + * ends on the not-found screen, which offers creating the page in place — + * title = the slug, so every link pointing at the address resolves (#47). + * Readers hit the 403 banner instead (the affordance is ungated by design). + * Wired into CI with the vault-import pack (#119). + */ +const BASE_URL = process.env.E2E_BASE_URL ?? 'http://localhost:5173'; + +async function insertWikilink(page: import('@playwright/test').Page, query: string): Promise { + const body = page.locator('.editor-content .ProseMirror'); + await body.click(); + await page.keyboard.type(`[[${query}`); + await expect(page.locator('.wikilink-suggest')).toBeVisible(); + await page.keyboard.press('Enter'); +} + +test('not-found screen creates the missing page in place', async ({ browser }) => { + const context = await contextForUser(browser, BASE_URL, 'fixture-user'); + const ponds = await context.request.get('/api/v1/ponds'); + const pond = (await ponds.json()).find((p: { type: string }) => p.type === 'personal'); + const ts = Date.now(); + const source = await ( + await context.request.post(`/api/v1/ponds/${pond.id}/pages`, { + data: { title: `NF Source ${ts}` }, + }) + ).json(); + + const page = await context.newPage(); + // Author a phantom wikilink, then follow it in read mode. + await page.goto(`/p/${pond.slug}/${source.slug}`); + await page.locator('.editor-page__mode-toggle').click(); + await insertWikilink(page, `nf-ghost-${ts}`); + await page.locator('.editor-page__mode-toggle').click(); // back to read mode + await page.locator('.editor-content a.wikilink', { hasText: `nf-ghost-${ts}` }).click(); + + // Dead end shows the message AND the way out. + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/nf-ghost-${ts}$`)); + await expect(page.locator('.form-banner--error, [role="alert"]').first()).toBeVisible(); + const createButton = page.locator('.create-missing-page__button'); + await expect(createButton).toBeVisible(); + + // Create → the editor mounts on the same URL. + await createButton.click(); + await expect(page.locator('.editor-content .ProseMirror')).toBeVisible(); + await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/nf-ghost-${ts}$`)); + + // The source's backlink panel proves the link resolved (id-based). + await page.reload(); + await expect(page.locator('.backlinks')).toBeVisible(); + await expect(page.locator('.backlinks__link', { hasText: `NF Source ${ts}` })).toBeVisible(); + + await context.close(); +}); + +test('a viewer without edit rights hits the 403 banner instead', async ({ browser }) => { + const owner = await contextForUser(browser, BASE_URL, 'fixture-user'); + const viewer = await contextForUser(browser, BASE_URL, 'fixture-viewer'); + const viewerId = (await (await viewer.request.get('/api/v1/auth/me')).json()).id as string; + const ts = Date.now(); + const pond = await ( + await owner.request.post('/api/v1/ponds', { data: { name: `NF Pond ${ts}` } }) + ).json(); + await owner.request.post(`/api/v1/ponds/${pond.id}/grants`, { + data: { + subjectType: 'user', + subjectId: viewerId, + role: 'reader', + scopeType: 'pond', + effect: 'allow', + }, + }); + + const page = await viewer.newPage(); + await page.goto(`/p/${pond.slug}/does-not-exist-${ts}`); + const createButton = page.locator('.create-missing-page__button'); + await expect(createButton).toBeVisible(); + await createButton.click(); + // The POST 403s for a reader; the banner appears, no editor mounts. + await expect(page.locator('.create-missing-page .form-banner--error')).toBeVisible(); + await expect(page.locator('.editor-content .ProseMirror')).toHaveCount(0); + + await owner.close(); + await viewer.close(); +}); diff --git a/apps/web/src/links/PhantomPagesView.tsx b/apps/web/src/links/PhantomPagesView.tsx index e398d4d..e0e6cb0 100644 --- a/apps/web/src/links/PhantomPagesView.tsx +++ b/apps/web/src/links/PhantomPagesView.tsx @@ -37,6 +37,9 @@ export function PhantomPagesView({ const page = await apiPost(`/ponds/${pondId}/pages`, { title: targetSlug }); await queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] }); await queryClient.invalidateQueries({ queryKey: ['pages', pondId] }); + // The graph views read this key — without it they kept showing the + // resolved target as a phantom (#115). + await queryClient.invalidateQueries({ queryKey: ['pond-links', pondId] }); navigate(`/p/${pondSlug}/${page.slug}`); } finally { setBusy(null); diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index dc69cee..14098ed 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -1,6 +1,6 @@ import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared'; import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; import { RefreshCw, Wifi, WifiOff } from 'lucide-react'; @@ -31,7 +31,7 @@ import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { usePageActionsSlot } from '../layout/page-actions'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; -import { ApiError, apiGet, apiPatch } from '../lib/api'; +import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api'; import { PageActions } from './PageActions'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; @@ -53,6 +53,58 @@ const DEFAULT_POND_FONTS = { type Mode = 'view' | 'edit'; /** Footer status glyph per collab connection state (M10 follow-up). */ +/** + * The way out of a phantom-wikilink dead end (issue #115): create the missing + * page in place. Title = the slug, so the generated slug matches the URL and + * every link pointing here resolves (the PhantomPagesView mechanic, #47). + * On success the invalidated page query refetches and mounts the editor — + * the URL already names the new page. + */ +function CreateMissingPage({ + pondId, + pageSlug, +}: { + pondId: string; + pageSlug: string; +}): React.JSX.Element { + const { t } = useTranslation('editor'); + const queryClient = useQueryClient(); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + async function create(): Promise { + setError(null); + setBusy(true); + try { + await apiPost(`/ponds/${pondId}/pages`, { title: pageSlug }); + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['page', pondId, pageSlug] }), + queryClient.invalidateQueries({ queryKey: ['pages', pondId] }), + queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] }), + queryClient.invalidateQueries({ queryKey: ['pond-links', pondId] }), + ]); + } catch (err) { + setError(err); + setBusy(false); + } + } + + return ( +
+

{t('notFound.hint')}

+ + +
+ ); +} + function ConnectionIcon({ status }: { status: string }): React.JSX.Element { if (status === 'connected') return ; if (status === 'offline') return ; @@ -350,10 +402,19 @@ export function PageEditorPage(): React.JSX.Element { // Editors get a distinguishable hint (and a way out) instead of a dead // end when the page they followed a link to is in the trash (#31). const trashed = page.error instanceof ApiError && page.error.body.code === 'page_trashed'; + // A plain not-found (typically a phantom wikilink, #115) offers creating + // the page right here. Deliberately ungated like the sidebar's "new + // page" button — non-editors get the 403 banner from the POST. + const missing = + !trashed && + Boolean(pond.data) && + page.error instanceof ApiError && + page.error.body.code === 'not_found'; return ( <> {trashed && {t('trash.restoreLink')}} + {missing && } ); } diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css index 6e7eb7a..78b5f05 100644 --- a/apps/web/src/styles/base.css +++ b/apps/web/src/styles/base.css @@ -1828,6 +1828,17 @@ button { border: 2px dashed var(--color-text-muted); } +/* "Create this page" on the not-found screen (issue #115). */ +.create-missing-page { + margin-top: var(--space-3); +} + +.create-missing-page__hint { + color: var(--color-text-muted); + font-size: 0.9rem; + margin: 0 0 var(--space-2); +} + /* Local neighborhood graph below the page content (issue #113). */ .local-graph { margin-top: var(--space-4); diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index bfc59ce..001e6c0 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -155,5 +155,9 @@ "phantomTooltip": "Diese Seite existiert noch nicht", "autocompleteLabel": "Auf eine Seite verlinken", "createHint": "Seite „{{title}}“ anlegen" + }, + "notFound": { + "hint": "Du kannst sie direkt hier anlegen — alle Wikilinks auf diese Adresse zeigen dann auf die neue Seite.", + "create": "Seite „{{slug}}“ anlegen" } } diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index ab76687..18541bc 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -155,5 +155,9 @@ "phantomTooltip": "This page does not exist yet", "autocompleteLabel": "Link to a page", "createHint": "Create page “{{title}}”" + }, + "notFound": { + "hint": "You can create it right here — every wikilink pointing at this address will resolve to the new page.", + "create": "Create the page “{{slug}}”" } }