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
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:
parent
ef973c90d6
commit
64e21e9f94
89
apps/web/e2e/create-missing-page.spec.ts
Normal file
89
apps/web/e2e/create-missing-page.spec.ts
Normal 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();
|
||||
});
|
||||
@ -37,6 +37,9 @@ export function PhantomPagesView({
|
||||
const page = await apiPost<PageView>(`/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);
|
||||
|
||||
@ -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<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 {
|
||||
if (status === 'connected') return <Wifi 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
|
||||
// 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 (
|
||||
<>
|
||||
<FormError error={pond.error ?? page.error} />
|
||||
{trashed && <Link to={`/p/${pondSlug}/trash`}>{t('trash.restoreLink')}</Link>}
|
||||
{missing && <CreateMissingPage pondId={pond.data!.id} pageSlug={pageSlug} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}”"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user