host: typed relations — Phase 3, tags as posts
A tag is just a post that is-a tag; tagging is a "tagged" edge to it. End to end: mark a post a tag, tag posts with it, see a post's tags and a tag's members. - helpers: host/blog-is-tag? (= is-a? slug "tag"), host/blog-tags (out tagged), host/blog-tagged-with (in tagged), host/blog-instances-of (a type's members, O(#subtypes) not O(#posts) — the efficient candidate source). - picker generalised to be KIND-AWARE and MULTI-INSTANCE: relate-options takes &kind=, candidates come from the kind's registry :candidates (all/tags/types); /relate-picker.js wires every .relate-picker box by data-kind (a Related picker and a Tags picker now coexist on the edit page). - render: post page gains a "Tags" block; a tag post additionally lists "Tagged with this" (its members). edit page: a Related editor + a Tags editor + an "is this post a tag" toggle (reuses /relate kind=is-a — no new route). - GOTCHA (again): host/blog--relation-editor read host/blog-out INSIDE its quasiquote -> VmSuspended/500 under http-listen + durable edges; moved the read to a let before the quasiquote (conformance can't see it — in-memory store; the ephemeral Playwright run caught it). 6 conformance tests (is-tag?, instances-of, tag+tagged-with, tagged picker offers only tags, related picker still all, is-a-tag toggle) -> 261/261. Playwright multi-picker 4/4. Verified live: ocaml made a tag, welcome tagged ocaml, Tags block + Tagged-with-this both render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -9,6 +9,10 @@ const USER = process.env.SX_ADMIN_USER || 'admin';
|
||||
const PASS = process.env.SX_ADMIN_PASSWORD || 'letmein';
|
||||
const HOST = 'picker-host'; // the post whose edit page we drive
|
||||
const LIMIT = 20; // host/blog--picker-limit
|
||||
// the Related picker box (the edit page now has one picker per kind)
|
||||
const REL = '.relate-picker[data-kind="related"]';
|
||||
const RELF = `${REL} .rp-filter`;
|
||||
const RELR = `${REL} .rp-results`;
|
||||
|
||||
// Navigate to a guarded path; the host redirects to /login?next=…, so fill the
|
||||
// form and we should land back on the original path (exercises the auth flow).
|
||||
@@ -22,35 +26,37 @@ async function loginTo(page, path) {
|
||||
}
|
||||
|
||||
test.describe('relate picker', () => {
|
||||
test('login redirect returns to the edit page', async ({ page }) => {
|
||||
test('edit page has Related + Tags pickers and an is-a-tag toggle', async ({ page }) => {
|
||||
await loginTo(page, `/${HOST}/edit`);
|
||||
await expect(page).toHaveURL(new RegExp(`/${HOST}/edit`));
|
||||
await expect(page.locator('#relate-filter')).toBeVisible();
|
||||
await expect(page.locator(RELF)).toBeVisible(); // Related picker
|
||||
await expect(page.locator('.relate-picker[data-kind="tagged"] .rp-filter')).toBeVisible(); // Tags picker
|
||||
await expect(page.getByRole('button', { name: 'Make this a tag' })).toBeVisible(); // toggle
|
||||
});
|
||||
|
||||
test('picker loads a page of candidates then loads more on scroll', async ({ page }) => {
|
||||
await loginTo(page, `/${HOST}/edit`);
|
||||
const rows = page.locator('#relate-results li');
|
||||
const rows = page.locator(`${RELR} li`);
|
||||
// initial JS load fills exactly one page
|
||||
await expect.poll(() => rows.count(), { timeout: 8000 }).toBe(LIMIT);
|
||||
// scroll the results box to the bottom -> infinite scroll fetches the rest
|
||||
await page.locator('#relate-results').evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
await page.locator(RELR).evaluate((el) => el.scrollTo(0, el.scrollHeight));
|
||||
await expect.poll(() => rows.count(), { timeout: 8000 }).toBeGreaterThan(LIMIT);
|
||||
});
|
||||
|
||||
test('typing in the filter narrows the candidates', async ({ page }) => {
|
||||
await loginTo(page, `/${HOST}/edit`);
|
||||
await expect.poll(() => page.locator('#relate-results li').count(), { timeout: 8000 }).toBeGreaterThan(0);
|
||||
await page.fill('#relate-filter', 'Item 13');
|
||||
await expect.poll(() => page.locator('#relate-results li').count(), { timeout: 8000 }).toBe(1);
|
||||
await expect(page.locator('#relate-results')).toContainText('Picker Item 13');
|
||||
await expect.poll(() => page.locator(`${RELR} li`).count(), { timeout: 8000 }).toBeGreaterThan(0);
|
||||
await page.fill(RELF, 'Item 13');
|
||||
await expect.poll(() => page.locator(`${RELR} li`).count(), { timeout: 8000 }).toBe(1);
|
||||
await expect(page.locator(RELR)).toContainText('Picker Item 13');
|
||||
});
|
||||
|
||||
test('clicking a candidate relates it (and it shows on the post page)', async ({ page }) => {
|
||||
await loginTo(page, `/${HOST}/edit`);
|
||||
await page.fill('#relate-filter', 'Item 07');
|
||||
await expect.poll(() => page.locator('#relate-results li').count(), { timeout: 8000 }).toBe(1);
|
||||
await page.locator('#relate-results button').first().click();
|
||||
await page.fill(RELF, 'Item 07');
|
||||
await expect.poll(() => page.locator(`${RELR} li`).count(), { timeout: 8000 }).toBe(1);
|
||||
await page.locator(`${RELR} button`).first().click();
|
||||
// form POST -> 303 back to the edit page; the related list now links the slug
|
||||
await expect(page).toHaveURL(new RegExp(`/${HOST}/edit`));
|
||||
await expect(page.locator('a[href="/picker-item-07/"]')).toHaveCount(1);
|
||||
|
||||
Reference in New Issue
Block a user