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 });
|
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);
|
||||||
|
|||||||
@ -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} />}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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}}”"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user