Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
aaed59a
feat(bylines): types and storage interfaces for custom fields
MohamedH1998 May 28, 2026
23a9e2f
feat(bylines): migration 041 for custom field tables
MohamedH1998 May 28, 2026
d4f6b87
test(migrations): assert byline-field tables in dialect-compat fresh-run
MohamedH1998 May 28, 2026
b41750c
feat(bylines): BylineSchemaRegistry with version counter
MohamedH1998 May 28, 2026
1bb4e48
feat(bylines): per-isolate field-defs cache + request-cache invalidat…
MohamedH1998 May 28, 2026
5069cba
feat(bylines): atomic version bumps around schema mutations for cache…
MohamedH1998 May 28, 2026
5ead669
feat(bylines): hydrate customFields in BylineRepository
MohamedH1998 May 28, 2026
b806591
feat(bylines): batched customFields hydration for getBylinesForEntries
MohamedH1998 May 28, 2026
d56f33f
feat(bylines): registry helpers, error codes, and reorder slug reserv…
MohamedH1998 May 28, 2026
1010934
feat(bylines): zod schemas for byline custom-field admin API
MohamedH1998 May 28, 2026
41389c8
feat(bylines): handler layer for byline-fields and update routes
MohamedH1998 May 28, 2026
01c6210
feat(bylines): admin API routes for byline custom fields
MohamedH1998 May 28, 2026
0ad8ef2
test(bylines): admin API coverage for byline custom fields
MohamedH1998 May 28, 2026
e934f25
fix(admin): RTL-safe spacing, Lingui placeholder, error states for by…
MohamedH1998 May 28, 2026
4df5a72
test(admin): byline-schema permission + sidebar visibility coverage
MohamedH1998 May 28, 2026
770810e
feat(admin): API client for byline custom-field schema
MohamedH1998 May 28, 2026
da4b381
feat(admin): register /byline-schema route
MohamedH1998 May 28, 2026
77c9a9f
fix(bylines): editors can read byline field defs via schema:read
MohamedH1998 May 28, 2026
614ca9b
feat(admin): custom field inputs in byline edit form
MohamedH1998 May 28, 2026
2201090
test(admin): byline edit form forwards customFields on save
MohamedH1998 May 28, 2026
e91860d
test(e2e): byline custom fields round-trip + changeset + query-counts…
MohamedH1998 May 29, 2026
d781492
feat(bylines): accept customFields on POST create route
MohamedH1998 May 29, 2026
66292f4
feat(admin): inline byline custom fields and surface schema link in p…
MohamedH1998 May 29, 2026
d1f4120
refactor(admin): drop Byline Schema entry from sidebar
MohamedH1998 May 29, 2026
5931817
fix(bylines): translatable hydration, url scheme, atomic create+updat…
MohamedH1998 May 29, 2026
45366c2
fix(bylines): parity-aware dirty + always-advance clean for the field…
MohamedH1998 May 29, 2026
278e9c8
fix(admin): unify byline-fields cache key and harden custom-field inputs
MohamedH1998 May 29, 2026
a5077f6
Merge branch 'main' into feat/extensible-bylines
MohamedH1998 Jun 1, 2026
095398f
ci: update query-count snapshots
emdashbot[bot] Jun 1, 2026
a65b98a
fix(bylines): qualify options.value in version SQL for postgres
MohamedH1998 Jun 1, 2026
abf097b
fix(bylines): lint
MohamedH1998 Jun 1, 2026
5382efe
fix(bylines): e2e test fix
MohamedH1998 Jun 1, 2026
8960afb
Update packages/core/src/database/repositories/byline.ts
MohamedH1998 Jun 1, 2026
077bdc0
fix(bylines): restore success return in coerceFieldValue url case
MohamedH1998 Jun 1, 2026
3ae6181
feat(bylines): cache field-defs promise to coalesce concurrent reads
MohamedH1998 Jun 2, 2026
27b2eef
style: format
emdashbot[bot] Jun 2, 2026
7a5ece5
Merge branch 'main' into feat/extensible-bylines
MohamedH1998 Jun 2, 2026
8053d0b
Merge branch 'main' into feat/extensible-bylines
MohamedH1998 Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/extensible-bylines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
"emdash": minor
"@emdash-cms/admin": minor
---

Adds custom fields to bylines. Sites can define site-specific byline metadata (Twitter handle, pronouns, company, localised job title, etc.) via the new `/byline-schema` admin screen, accessed from the **Byline schema** link button at the top of the Bylines admin page (admin-only).

Per-field `translatable` flag picks whether values are stored per-locale (one value per locale row in a `translation_group`) or shared across every locale variant of the same byline identity. Schema management is gated by `schema:manage`; value editing by `bylines:manage`.

Custom-field values can be set at both create and update time. `POST` and `PUT` on `/_emdash/api/admin/bylines` accept the same `customFields` map; the row write and the custom-field writes share a single transaction on Node/PG so a partial failure rolls both back. On D1 (no transactions), a retry POST is treated as completing an abandoned create iff three checks all pass: (a) every fixed column on the existing row matches the new payload (`displayName`, `bio`, `avatarMediaId`, `websiteUrl`, `userId`, `isGuest`, effective locale — null-vs-undefined normalised); (b) the existing row's `translationGroup` matches what a fresh create with the same input would produce (`sourceGroup` when `translationOf` is present, `existing.id` when it isn't); (c) every custom-field value already stored on the row appears in the input payload with an equal value (subset-match, so partial mid-loop crashes can be completed). The recovery branch is conservative on every axis: any fixed-column mismatch, any translation-group mismatch, any overlapping custom-field value mismatch, an input that omits a key the existing row stores, or an input with no custom fields at all → standard `CONFLICT`. Validation runs before any DB write so a bad value (unknown slug, type mismatch, select-choice miss, non-URL or non-http(s) URL for a `url` field) returns 400 `VALIDATION_ERROR` without leaving partial state behind. In the admin, registered fields render inline with Name, Bio, etc. — no separate section header — and are available in the **New byline** dialog as well as edit.

`BylineSummary` gains an optional `customFields: Record<string, CustomFieldValue>` property. Existing object-literal consumers stay source-compatible because the property is optional and runtime always returns `{}` when no fields are registered.

Hydration is symmetric with writes: rows are only applied to a byline when they live in the table matching the field's current `translatable` flag, so stale rows from a `translatable` flip can't leak into hydrated output. Schema mutations on `/byline-schema` invalidate the same `byline-fields` query the byline form reads, so newly-registered fields appear in the editor without a page reload. `url` field values are parsed with `new URL(...)` AND restricted to `http:` / `https:` schemes at write time so they can't ship `javascript:` / `data:` / `mailto:` payloads to link rendering. The `BylineFieldEditor` "Save" button stays disabled until a `select` field has at least one option; and select-option lists are accumulated on a null-prototype object so option values that collide with `Object.prototype` keys render correctly.

The field-definitions cache uses parity on `options.byline_fields_version` as a dirty bit: schema mutations flip the counter to odd before the write lands and to a **new even** value after, with the cache treating any odd version as "bypass the global holder, read fresh from the DB". `markVersionDirty` is parity-aware (ensures odd, no-op if already odd) so a crashed prior attempt's leftover dirty state can't get inverted. `markVersionClean` is **always-advance** (`+2` when starting even, `+1` when starting odd) so two concurrent mutators can't collapse on the same even key and pin the cache on a partial-set snapshot — every committed mutation produces an observable counter change for cache readers. Idempotent-retry exits (`FIELD_EXISTS` on create, `FIELD_NOT_FOUND` on update/delete, no-op input on update) call `markVersionClean` too, which doubles as both the dirty-crash recovery and the false-clean recovery. All version writes use `INSERT … ON CONFLICT DO UPDATE` so a missing options row can't silently turn invalidation into a no-op.

Implements [#1174](https://github.com/emdash-cms/emdash/discussions/1174). Builds on the bylines-i18n foundation from [#1146](https://github.com/emdash-cms/emdash/pull/1146).
139 changes: 139 additions & 0 deletions e2e/tests/byline-fields.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* Byline Custom Fields E2E (Phase 7 of Discussion #1174)
*
* Proves the full round-trip: an admin registers a custom field via
* `/byline-schema`, a byline is given a value through the edit form,
* and the GET response on `/api/admin/bylines/{id}` returns the value
* via `customFields.{slug}` — exercising every layer the PR touches
* (registry, repository hydration, admin API, schema management UI,
* byline edit form).
*
* Workers are pinned to 1 in `playwright.config.ts`, and
* `global-setup.ts` rebuilds the fixture DB per `pnpm test:e2e`
* invocation — so a fixed slug works without conflicting with itself
* across runs. We still suffix with a timestamp because the same
* `pnpm test:e2e` invocation may run this spec alongside others that
* touch the bylines table.
*/

import { test, expect } from "../fixtures";

function apiHeaders(token: string, baseUrl: string): Record<string, string> {
return {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
"X-EmDash-Request": "1",
Origin: baseUrl,
};
}

test.describe("Byline custom fields", () => {
test.beforeEach(async ({ admin }) => {
await admin.devBypassAuth();
});

test("custom field value round-trips through the API end to end", async ({
admin,
page,
serverInfo,
}) => {
const unique = Date.now();
const fieldSlug = `job_title_${unique}`;
const fieldLabel = `Job title ${unique}`;
const bylineDisplayName = `Jane Custom ${unique}`;
const bylineSlug = `jane-custom-${unique}`;
const fieldValue = "Editor";

// ---------------------------------------------------------------
// 1. Register a custom field via /byline-schema
// ---------------------------------------------------------------

await admin.goto("/byline-schema");
await admin.waitForLoading();

await page.getByRole("button", { name: "New field" }).click();
// `BylineFieldEditor` auto-fills the slug from the label
// (Phase 5). Filling label first matches the production UX
// flow; overriding slug afterwards proves the input is editable.
await page.getByLabel("Label").fill(fieldLabel);
await page.getByLabel("Slug").fill(fieldSlug);
// Type defaults to `string` — leave it. `translatable` defaults
// to true — leave it. Required stays off.
await page.getByRole("button", { name: "Create field" }).click();

// The new field row should show up in the schema table. Scope
// the assertion to the table because the success toast also
// contains the field label ("Created \"{label}\"."), and
// Playwright's strict-mode locator rejects ambiguous matches.
await expect(page.locator("table").getByText(fieldLabel)).toBeVisible({
timeout: 5000,
});

// ---------------------------------------------------------------
// 2. Create a byline via /bylines and select it
// ---------------------------------------------------------------

await admin.goto("/bylines");
await admin.waitForLoading();

await page.getByRole("button", { name: "New" }).click();
await page.getByLabel("Display name").fill(bylineDisplayName);
await page.getByLabel("Slug").fill(bylineSlug);
// Guest byline keeps the form simple — no user-link side quest.
await page.getByRole("switch", { name: "Guest byline" }).click();
await page.getByRole("button", { name: "Create" }).click();

// After create, the form moves to edit mode and the sidebar list
// re-renders with the new byline highlighted. Custom-field inputs
// are gated on `selected`, so they appear only after create lands.
await expect(page.getByRole("button", { name: bylineDisplayName })).toBeVisible({
timeout: 5000,
});

// ---------------------------------------------------------------
// 3. Fill the custom field input and save
// ---------------------------------------------------------------

await expect(page.getByLabel(fieldLabel)).toBeVisible();
await page.getByLabel(fieldLabel).fill(fieldValue);
await page.getByRole("button", { name: "Save" }).click();

// ---------------------------------------------------------------
// 4. Verify the round-trip via the REST API
// ---------------------------------------------------------------

// Find the byline id via the list endpoint (the sidebar's selected
// row is keyed by id internally; reading via API is easier than
// scraping the DOM). Filter by slug to avoid pagination concerns.
const headers = apiHeaders(serverInfo.token, serverInfo.baseUrl);
const listResponse = await fetch(
`${serverInfo.baseUrl}/_emdash/api/admin/bylines?search=${encodeURIComponent(bylineSlug)}`,
{ headers },
);
expect(listResponse.ok).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const listBody: any = await listResponse.json();
const created = (listBody.data?.items ?? []).find(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(b: any) => b.slug === bylineSlug,
);
expect(created).toBeTruthy();
expect(created.displayName).toBe(bylineDisplayName);

const getResponse = await fetch(
`${serverInfo.baseUrl}/_emdash/api/admin/bylines/${created.id}`,
{ headers },
);
expect(getResponse.ok).toBe(true);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getBody: any = await getResponse.json();
const fetched = getBody.data;

// The core assertion: the value we typed in the form is what
// the API returns for the field slug. Goes through every layer:
// admin UI form state → PATCH body → handler → BylineRepository
// .update → translatable value table → hydration → GET response.
expect(fetched.customFields).toBeTruthy();
expect(fetched.customFields[fieldSlug]).toBe(fieldValue);
});
});
Loading
Loading