Offer creating the page on the not-found screen (#115)
Some checks failed
CD / Build and push images (push) Successful in 4m20s
CD / Deploy to Test (push) Successful in 9s
CI / Lint, typecheck, test (push) Failing after 4m38s
CI / Auth e2e pack (push) Has been skipped
CI / Import/export fidelity gate (push) Has been skipped
CI / Build container images (push) Has been skipped
CD / Smoke tests against Test (push) Successful in 1m20s
CD / Promote to Int (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-14 16:45:27 +02:00
parent ef973c90d6
commit 64e21e9f94
6 changed files with 174 additions and 2 deletions

View File

@ -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<void> {
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();
});

View File

@ -37,6 +37,9 @@ export function PhantomPagesView({
const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, { title: targetSlug }); const page = await apiPost<PageView>(`/ponds/${pondId}/pages`, { title: targetSlug });
await queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] }); await queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] });
await queryClient.invalidateQueries({ queryKey: ['pages', 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}`); navigate(`/p/${pondSlug}/${page.slug}`);
} finally { } finally {
setBusy(null); setBusy(null);

View File

@ -1,6 +1,6 @@
import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared'; import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared';
import type { PageListItemView, PageStateView, PondView } 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 { Collaboration } from '@tiptap/extension-collaboration';
import { EditorContent, useEditor } from '@tiptap/react'; import { EditorContent, useEditor } from '@tiptap/react';
import { RefreshCw, Wifi, WifiOff } from 'lucide-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 { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context';
import { usePageActionsSlot } from '../layout/page-actions'; import { usePageActionsSlot } from '../layout/page-actions';
import { useForceSidebarHidden } from '../layout/sidebar-chrome'; 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 { PageActions } from './PageActions';
import { recallPage, rememberPage } from '../offline/page-cache'; import { recallPage, rememberPage } from '../offline/page-cache';
import { PluginBlockContext } from '../editor/plugin-block-context'; import { PluginBlockContext } from '../editor/plugin-block-context';
@ -53,6 +53,58 @@ const DEFAULT_POND_FONTS = {
type Mode = 'view' | 'edit'; type Mode = 'view' | 'edit';
/** Footer status glyph per collab connection state (M10 follow-up). */ /** 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<unknown>(null);
const [busy, setBusy] = useState(false);
async function create(): Promise<void> {
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 (
<div className="create-missing-page">
<p className="create-missing-page__hint">{t('notFound.hint')}</p>
<FormError error={error} />
<button
type="button"
className="button create-missing-page__button"
disabled={busy}
onClick={() => void create()}
>
{t('notFound.create', { slug: pageSlug })}
</button>
</div>
);
}
function ConnectionIcon({ status }: { status: string }): React.JSX.Element { function ConnectionIcon({ status }: { status: string }): React.JSX.Element {
if (status === 'connected') return <Wifi aria-hidden />; if (status === 'connected') return <Wifi aria-hidden />;
if (status === 'offline') return <WifiOff aria-hidden />; if (status === 'offline') return <WifiOff aria-hidden />;
@ -350,10 +402,19 @@ export function PageEditorPage(): React.JSX.Element {
// Editors get a distinguishable hint (and a way out) instead of a dead // 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). // 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'; 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 ( return (
<> <>
<FormError error={pond.error ?? page.error} /> <FormError error={pond.error ?? page.error} />
{trashed && <Link to={`/p/${pondSlug}/trash`}>{t('trash.restoreLink')}</Link>} {trashed && <Link to={`/p/${pondSlug}/trash`}>{t('trash.restoreLink')}</Link>}
{missing && <CreateMissingPage pondId={pond.data!.id} pageSlug={pageSlug} />}
</> </>
); );
} }

View File

@ -1828,6 +1828,17 @@ button {
border: 2px dashed var(--color-text-muted); 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 neighborhood graph below the page content (issue #113). */
.local-graph { .local-graph {
margin-top: var(--space-4); margin-top: var(--space-4);

View File

@ -155,5 +155,9 @@
"phantomTooltip": "Diese Seite existiert noch nicht", "phantomTooltip": "Diese Seite existiert noch nicht",
"autocompleteLabel": "Auf eine Seite verlinken", "autocompleteLabel": "Auf eine Seite verlinken",
"createHint": "Seite „{{title}}“ anlegen" "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"
} }
} }

View File

@ -155,5 +155,9 @@
"phantomTooltip": "This page does not exist yet", "phantomTooltip": "This page does not exist yet",
"autocompleteLabel": "Link to a page", "autocompleteLabel": "Link to a page",
"createHint": "Create page “{{title}}”" "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}}”"
} }
} }