diff --git a/src/apps/duly.app.ts b/src/apps/duly.app.ts index 4b017d6..0990229 100644 --- a/src/apps/duly.app.ts +++ b/src/apps/duly.app.ts @@ -48,6 +48,30 @@ export const DulyApp = App.create({ // nav item carries `dashboardName` (resolved against the dashboards // barrel), never an `objectName` — nothing on it is entered. { id: 'nav_duty_health', type: 'dashboard', dashboardName: 'duly_duty_health', label: 'Duty health', icon: 'activity' }, + // The way into `duly_member` (`src/pages/member.page.ts`) — "点开任何 + // 一个人看全貌". A record page is reached by opening a RECORD, so the + // nav entry is the people list, not the page: a `type: 'page'` item + // routes through objectui's `PageView`, which mounts no + // `RecordContextProvider`, and every `record:related_list` on that page + // would then have a null parent and render nothing. Placed directly + // under the dashboard because the dashboard names who to look at and + // this is where you go to look at them. + // + // `requiresObject` is load-bearing twice over. It is the platform's own + // idiom for pointing nav at a RUNTIME-provided object, and without it + // `defineStack` refuses the stack outright: the cross-reference check + // resolves `objectName` against `config.objects` only, and exempts an + // entry that declares the dependency. It is also the right runtime + // behaviour — the entry hides instead of 404-ing where `sys_user` is + // not registered. + { + id: 'nav_people', + type: 'object', + objectName: 'sys_user', + requiresObject: 'sys_user', + label: 'People', + icon: 'users-round', + }, { id: 'nav_late', type: 'object', objectName: 'duly_task', viewName: 'late', label: 'Late', icon: 'alert-circle' }, { id: 'nav_stalled', type: 'object', objectName: 'duly_task', viewName: 'stalled', label: 'Not moving', icon: 'pause-circle' }, { id: 'nav_assignments', type: 'object', objectName: 'duly_assignment', viewName: 'sent_by_me', label: 'Assignments', icon: 'send' }, diff --git a/src/pages/index.ts b/src/pages/index.ts index 8370bca..8270f49 100644 --- a/src/pages/index.ts +++ b/src/pages/index.ts @@ -1,4 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { MemberPage } from './member.page.js'; + // // Barrel for src/pages/. // @@ -13,4 +16,4 @@ // makes `name` optional and fails the assignment. A named array is `never[]` // while empty and infers correctly the moment something is pushed into it. -export const dulyPages = []; +export const dulyPages = [MemberPage]; diff --git a/src/pages/member.page.ts b/src/pages/member.page.ts new file mode 100644 index 0000000..89cc2fa --- /dev/null +++ b/src/pages/member.page.ts @@ -0,0 +1,547 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { definePage } from '@objectstack/spec/ui'; + +/** + * `duly_member` — the manager's view of ONE person, entered by nobody. + * + * The screen that removes the "can you send me a status update" mail. A manager + * opens a person and reads six things in order: who they are, what is on fire + * right now, what this period asks of them, what they hold permanently, what + * has moved lately, and what was put on them by someone else. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHAT IS DELIBERATELY NOT HERE + * ═══════════════════════════════════════════════════════════════════════════ + * + * **The person's work log. Not filtered, not counted, not "0 entries".** The + * object is `readScope: 'own'` for every position + * (`src/security/permission-sets.ts`), and the only widening the product allows + * is a record's own `visibility: 'manager'` flag. A count is not a redaction: a + * manager shown "14 entries" has learned that the log exists, that it is being + * watched, and roughly how much is in it — which is enough to stop people + * keeping one, and the log is the one record this module exists to produce. + * + * Its machine name is deliberately absent from this file, including from this + * comment, because the card's acceptance is a GREP and a grep cannot read + * intent: a hit is a hit, and the next author to run it should get silence + * rather than a paragraph to judge. The name, the reasoning and the guard live + * together in `test/member-page.test.ts`, which greps this source and walks the + * parsed page. + * + * **Any editable control.** A manager's only write in this product is + * assigning, and that lives on `duly_assignment` with its own action. So this + * page authors no `record:details` (whose `inlineEdit` defaults ON where the + * object is editable), no `record:quick_actions`, no `element:form` / + * `element:button` / `element:text_input` / `element:record_picker`, no `add` + * picker and no `actions` list on any related list. The highlight chips carry + * `readonly: true`, which the renderer's `HeaderHighlight` gate enforces. + * See "MEASURED PLATFORM GAPS" §3 for the one affordance this page cannot + * close from metadata. + * + * **Any comparison to other people.** No percentile, no team average, no rank, + * no "N of M". Their own trend over time is fine; a position in a distribution + * is a performance score, and item counts are never ranked or compared anywhere + * in this product (`AGENTS.md` — product invariants). + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHY THIS IS A `record` PAGE OVER `sys_user`, AND HOW IT IS REACHED + * ═══════════════════════════════════════════════════════════════════════════ + * + * Everything on this page has to be scoped to the person being read. Measured + * on `@objectstack/spec` 17.2.0, exactly ONE authorable component binds itself + * to the record in context: `record:related_list`, via `relationshipField` (the + * child field holding this record's `relationshipValueField`, default `id`). + * Every other data-bearing component takes a `FilterCondition`, whose entire + * dynamic vocabulary is `CONTEXT_TOKENS` — `{current_user_id}` and + * `{current_org_id}` (`@objectstack/spec/data`, `context-tokens.zod.ts`) — both + * of which name the VIEWER, never the record. See gap §1. + * + * So the page is `type: 'record'`, `object: 'sys_user'`, and it is reached the + * way record pages are reached: by opening a person. The Team nav group gains a + * `sys_user` entry ("People") for that; a `type: 'page'` nav item would route + * through `PageView`, which mounts no `RecordContextProvider`, so every related + * list below would have a null parent and render nothing. + * + * `kind: 'full'` with explicit regions, NOT `kind: 'slotted'`, and that is a + * product decision rather than a style one. A slotted page falls through to + * `buildDefaultPageSchema`, whose `tabs` synthesizer generates one related list + * per object holding an FK to `sys_user` — which on this stack includes the + * work-log object named above. The one thing this page must never show is the + * thing the default layout would add for free. + * + * ═══════════════════════════════════════════════════════════════════════════ + * MEASURED PLATFORM GAPS — filed, not worked around (AGENTS.md rule 9) + * ═══════════════════════════════════════════════════════════════════════════ + * + * Each of these was measured against the installed 17.2.0 packages before it + * was written down, and each is FILED at `objectstack-ai/objectui` rather than + * faked here. What the page does INSTEAD is named in each entry, and every + * entry carries its issue number — a gap with no number is a workaround + * wearing a comment (AGENTS.md rule 9). + * + * **§1 — No record-context token for filter values** (objectui#7297). `element:number` + * (`object` + `aggregate: 'count'` + `filter`) is the component that renders a + * number, and its `filter` is a plain `FilterCondition`. `resolveContextTokens` + * (`@object-ui/core`, `filter-tokens.ts`) re-exports the spec's `CONTEXT_TOKENS` + * verbatim and resolves those two names only; `ElementNumberRenderer` passes + * `props.filter` straight to the adapter with nothing record-shaped added. So a + * count of THIS person's open tasks is not authorable — an `element:number` on + * this page would count the whole org, which is both wrong and the + * comparison-to-peers this card forbids. + * → INSTEAD: "Right now" is three `record:related_list`s. Each is correctly + * record-scoped, and each renders a real count: `RelatedList` draws a badge + * carrying the server `total` for the collection, not the loaded page. + * + * **§2 — `record:related_list` cannot group** (objectui#7301). `RecordRelatedListProps` has + * `sort`, `filter`, `limit` and `columns`, and no `groupBy`; `grouping` exists + * only on a grid LIST VIEW, which a related list does not render (it draws its + * own table). "Grouped by frequency" is therefore not authorable at this + * position. + * → INSTEAD: "This period" leads with the `frequency` column and sorts on it, + * so each rhythm reads as a contiguous block. That is weaker than grouping + * and it is not pretended otherwise. + * + * **§3 — A page cannot declare its related lists read-only** (objectui#7300). There is no + * `readonly` on `PageComponentSchema` — deliberately, ruled 2026-08-12: + * "editability lives on fields". That ruling does not reach a related list's + * "+ New" / row-edit / row-delete affordances, which are not fields: they are + * resolved by the HOST (`RelatedRecordActionsBridge`) from the CHILD object's + * `userActions` intersected with the principal's grant, and `duly_task` grants + * `allowCreate` / `allowEdit` to every Duly position. So a viewer of this page + * can be offered "+ New" on a task list, and no key on this page can say + * otherwise. Object-level `userActions` would close it everywhere, which is a + * different (and wrong) change. + * → INSTEAD: this page declares nothing editable it CAN decline (no `add`, no + * `actions`, no editable component types, `readonly` chips) and the residue + * is filed. `test/member-page.test.ts` pins the authorable half. + * + * **§4 — Related-list columns cannot cross a lookup** (objectui#7301). `RelatedList` resolves + * lookup LABELS but has no dotted-path column support, so "who assigned each" + * cannot be `assignment.assigner` on a `duly_task` list. + * → INSTEAD: the task list carries the `assignment` column, which names the + * fan-out the task came out of. One click from the assigner, not zero. + * + * **§6 — `record:related_list` cannot bind to a multi-value field** + * (objectui#7299). The other + * route to the assigner was a list of `duly_assignment` bound on `assignees` + * (`multiple: true`) — the field that actually names this person. It was + * authored, run against the seeded demo, and REFUSED by the driver, because + * `RelatedList` builds its parent filter as bare equality + * (`{[relationshipField]: parentId}`) with no membership spelling available to + * the author: + * + * GET /api/v1/data/duly_assignment?filter=["assignees","=",""] + * 400 INVALID_FILTER — The bare equality spelling { "assignees": value } + * WAS NOT APPLIED: "assignees" is a multi-value (or otherwise JSON-valued) + * field, stored by this driver as a JSON TEXT column … Use "$contains" for + * membership … + * + * Credit where it is due: the driver refuses LOUDLY and names the working + * spelling, so this is a gap rather than a silent wrong answer. But + * `$contains` is not reachable from `RecordRelatedListProps` — the parent + * filter is the component's, not the author's — so the list cannot be written + * correctly at all. + * → INSTEAD: nothing. The list was removed rather than left rendering an + * error, and "showing who assigned each" is the one line of this card that + * is not fully delivered. Recorded on the issue and filed upstream. + * + * **§5 — Two record pages for one object resolve by declaration order** + * (objectui#7298). + * `@objectstack/platform-objects` ships `sys_user_detail` + * (`type: 'record'`, `object: 'sys_user'`, `isDefault: true`), and + * `usePageAssignment` picks among candidates by `(b.priority ?? 0) - + * (a.priority ?? 0)` — but `priority` is not a key `PageSchema` declares, so an + * author cannot write the tiebreaker the renderer reads. With both pages at 0 + * the winner is metadata load order. + * → INSTEAD: nothing, because there is nothing authorable. `isDefault` is + * left `false` here so this page never claims to be the default while the + * platform's page claims the same thing, and the observed resolution on + * this checkout is recorded in the PR. + * + * **§7 — The auto-appended discussion panel cannot be declined by a page, and + * it carries a comment box** (objectui#7298, the second instance there). Found in the browser, not in the schema. + * `RecordDetailView` appends a `RecordChatterPanel` to every record page when + * the object does not set `enable.feeds: false`, hard-coded with + * `showCommentInput: true`, `enableReactions: true`, `enableThreading: true`. + * `sys_user` is a platform object under `protection: { lock: 'full' }`, so the + * object-side switch is not ours; the renderer's own opt-out — + * `assignedPage.disableDiscussion === true`, named in its comment — is NOT a + * key `PageSchema` declares, and `PageSchema` is a `strictObject`, so writing + * it is a hard parse error. Same shape as §5: the renderer reads a key the + * author cannot write. + * → INSTEAD, and this one IS closed: placing an EXPLICIT + * `record:discussion` suppresses the auto-append + * (`hasExplicitDiscussion`), and the explicit node's config is the + * author's. `feed.showCommentInput: false` is honoured — the composer is + * gated on `config?.showCommentInput !== false` in + * `RecordActivityTimeline`, with objectui's own test pinning + * "hides it even when the host CAN persist a comment". So the page ships + * the record's history with every write affordance off, which is what + * "read-only" has to mean here. Filed anyway: closing a write surface + * should not require declaring the component that opens it. + * + * ═══════════════════════════════════════════════════════════════════════════ + * WHAT AN EMPTY PAGE MEANS HERE + * ═══════════════════════════════════════════════════════════════════════════ + * + * Every list below reads `duly_task` / `duly_duty`, both `sharingModel: + * 'private'`, both granted to managers at `readScope: 'unit_and_below'` + * (ADR-0057). This checkout is open-edition: `@objectstack/security-enterprise` + * is absent, the depth scopes fall back to owner-only, and the page therefore + * looks EMPTY for anyone but yourself. That is the edition, not a defect + * (AGENTS.md rule 7). `test/member-page.test.ts` asserts the authored scopes + * and filters, never resolved rows — resolved rows here would only ever pin the + * fallback. + */ +export const MemberPage = definePage({ + name: 'duly_member', + label: 'Member', + description: + 'One person, whole: what is open right now, what this period asks of them, what they hold permanently, and what has moved lately — read-only, entered by nobody.', + icon: 'user-round', + + type: 'record', + object: 'sys_user', + template: 'default', + kind: 'full', + + // Left FALSE deliberately — see gap §5. The platform's own `sys_user_detail` + // already declares `isDefault: true` for this object, and two pages both + // claiming it would make the flag a lie on whichever one loses. + isDefault: false, + + regions: [ + // ── 1. Header — who this is ─────────────────────────────────────────── + { + name: 'header', + width: 'full', + components: [ + { + id: 'member_identity', + type: 'record:highlights', + properties: { + // `readonly: true` is not decoration on these three: the chip + // renderer's own gate refuses inline editing on a chip carrying it + // (`HeaderHighlight`), which is the enforced half of "nothing on + // this page is editable". All three are `readonly` on `sys_user` + // too — `manager_id` and `primary_business_unit_id` under ADR-0092 + // — and saying it here keeps the page's promise independent of the + // platform object's flags. + fields: [ + { name: 'name', readonly: true }, + { name: 'primary_business_unit_id', readonly: true }, + { name: 'manager_id', readonly: true }, + ], + layout: 'horizontal', + }, + }, + { + // Position is the fourth thing the header owes, and it is NOT a + // column on `sys_user`: a position is assigned through the + // `sys_user_position` junction, whose `position` holds + // `sys_position.name` (ADR-0057 D4 / ADR-0090 D3). A junction is a + // related list or it is nothing — there is no scalar to promote into + // a chip. This is the same shape the platform's own user page uses. + id: 'member_position', + type: 'record:related_list', + properties: { + objectName: 'sys_user_position', + relationshipField: 'user_id', + title: 'Position', + columns: ['position', 'business_unit_id'], + limit: 3, + // A person holds one or two positions, not a list worth paging. + showViewAll: false, + }, + }, + ], + }, + + // ── The body, in reading order ──────────────────────────────────────── + { + name: 'main', + width: 'full', + components: [ + // ── 2. Right now ────────────────────────────────────────────────── + { + id: 'heading_right_now', + type: 'element:text', + properties: { + content: { en: 'Right now', 'zh-CN': '当下' }, + variant: 'heading', + }, + }, + { + id: 'right_now_note', + type: 'element:text', + properties: { + content: { + en: 'Open work, then the part of it that is past its grace, then the part that has not been touched in a fortnight. The third is the one worth acting on: it is the only one that fires before a deadline does.', + 'zh-CN': '先是未完成的工作,再是其中已过宽限期的部分,最后是两周无人触碰的部分。值得立刻处理的是第三项——只有它会在到期之前就发出信号。', + }, + variant: 'caption', + }, + }, + { + id: 'right_now_open', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Open', + // `status` is stored and indexed; there is no `is_open` to ask and + // there never will be (AGENTS.md rule 5). + filter: [{ field: 'status', operator: 'in', value: ['open', 'in_progress'] }], + columns: ['subject', 'status', 'due_date', 'period_key'], + sort: [{ field: 'due_date', order: 'asc' }], + limit: 5, + }, + }, + { + id: 'right_now_late', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Late', + // Late = past the grace the duty granted AT DISPATCH, and still + // open. `late_after` is `due_date + duty.grace_days` stamped once + // on the row, so this is an ordinary date comparison against a + // stored, indexed column — the same filter `duly_task`'s `late` + // view asks, deliberately, so one person is not late on one screen + // and on time on another (#48). + filter: [ + { field: 'late_after', operator: 'less_than', value: '{today}' }, + { field: 'status', operator: 'in', value: ['open', 'in_progress'] }, + ], + // `late_after` is carried, not just filtered on: a list that will + // not show you why it thinks a task is late is the complaint #48 + // was about. + columns: ['subject', 'status', 'late_after', 'period_key'], + sort: [{ field: 'late_after', order: 'asc' }], + limit: 5, + }, + }, + { + id: 'right_now_stalled', + type: 'record:related_list', + // The emphasis the card asks for, expressed in the one styling + // channel that is build-independent on a metadata-authored page + // (ADR-0065 scoped styles — an authored Tailwind `className` would + // silently produce no CSS, since the build-time Tailwind never scans + // runtime metadata). + responsiveStyles: { + large: { + borderLeft: '3px solid #8C6512', + paddingLeft: '12px', + }, + }, + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Not moving', + // Stagnation: open, and untouched for a fortnight. The earliest + // honest warning a manager gets, because it fires long before the + // due date does. + filter: [ + { field: 'status', operator: 'in', value: ['open', 'in_progress'] }, + { field: 'last_update_at', operator: 'less_than', value: '{14_days_ago}' }, + ], + columns: ['subject', 'status', 'last_update_at', 'due_date'], + sort: [{ field: 'last_update_at', order: 'asc' }], + limit: 5, + }, + }, + + { id: 'rule_after_right_now', type: 'element:divider' }, + + // ── 3. This period ──────────────────────────────────────────────── + { + id: 'heading_this_period', + type: 'element:text', + properties: { + content: { en: 'This period', 'zh-CN': '本周期' }, + variant: 'heading', + }, + }, + { + id: 'this_period_note', + type: 'element:text', + properties: { + content: { + en: 'The duties the organisation put on this person — from the role catalog, or assigned by a manager. Self-declared duties are their own record-keeping and are not listed here. Frequency leads the row so a monthly rhythm reads as one block and an annual one as another.', + 'zh-CN': '组织交给这个人的职责——来自岗位职责库,或由主管指派。自行申报的职责属于本人的记录,不列在这里。频率排在每行最前,因此按月的节奏与按年的节奏各自读作一块。', + }, + variant: 'caption', + }, + }, + { + id: 'this_period_duties', + type: 'record:related_list', + properties: { + objectName: 'duly_duty', + relationshipField: 'owner', + title: 'Governed duties', + // `source` is the CALIBER column and the only one this product + // lets a metric or a manager-facing lens read: `catalog` and + // `assigned` are governed, `self` is the person's own note to + // themselves and is never scored, ranked or surfaced up the line. + // + // `form: 'recurring'` because this section is about the RHYTHM. + // One-offs have no period and standing duties have no task by + // construction — the latter get their own section below, which is + // the whole point of the two being different sections. + filter: [ + { field: 'source', operator: 'in', value: ['catalog', 'assigned'] }, + { field: 'form', operator: 'equals', value: 'recurring' }, + { field: 'status', operator: 'equals', value: 'active' }, + ], + // Frequency first — see gap §2: sorting is as close to grouping as + // this component gets, so the grouping key has to be the thing the + // eye lands on. + columns: ['frequency', 'name', 'due_anchor', 'status'], + sort: [ + { field: 'frequency', order: 'asc' }, + { field: 'name', order: 'asc' }, + ], + limit: 20, + }, + }, + + { id: 'rule_after_this_period', type: 'element:divider' }, + + // ── 4. Standing duties ──────────────────────────────────────────── + { + id: 'heading_standing', + type: 'element:text', + properties: { + content: { en: 'Standing duties', 'zh-CN': '常设职责' }, + variant: 'heading', + }, + }, + { + id: 'standing_note', + type: 'element:text', + properties: { + content: { + en: 'These never complete. "Keep the register current", "answer the duty phone" — they are held, not finished, so there is nothing here to tick. A control that implied otherwise would be a bug, not a convenience.', + 'zh-CN': '这些永远不会完成。「保持台账更新」「接听值班电话」——它们是被持有的,不是被做完的,所以这里没有可勾选的东西。任何暗示可以勾掉的控件都是缺陷,而不是便利。', + }, + variant: 'caption', + }, + }, + { + id: 'standing_duties', + type: 'record:related_list', + properties: { + objectName: 'duly_duty', + relationshipField: 'owner', + title: 'Held permanently', + // Straight from `duly_duty.form`, NOT joined through tasks: a + // standing duty never generates one, so a task-side query would + // return exactly nothing and read as "this person holds none". + filter: [{ field: 'form', operator: 'equals', value: 'standing' }], + // No `status` column and no completion anything. `duly_duty.status` + // is active / paused / retired — lifecycle, not completion — and + // putting it beside these rows invites reading a state machine into + // work that has none. `business_unit` instead: whose register it is. + columns: ['name', 'business_unit', 'source'], + sort: [{ field: 'name', order: 'asc' }], + limit: 20, + }, + }, + + { id: 'rule_after_standing', type: 'element:divider' }, + + // ── 5. Recent activity ──────────────────────────────────────────── + { + id: 'heading_recent', + type: 'element:text', + properties: { + content: { en: 'Recent activity', 'zh-CN': '最近动态' }, + variant: 'heading', + }, + }, + { + id: 'recent_activity', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Last touched', + // No filter: "what has moved" includes the things that moved into + // `done`, `skipped` and `cancelled`, which are exactly the states a + // manager is looking for evidence of. `last_update_at` is stamped + // by `task.hook.ts` on every status change and note edit, and it is + // indexed. + columns: ['subject', 'status', 'last_update_at', 'period_key'], + sort: [{ field: 'last_update_at', order: 'desc' }], + limit: 20, + }, + }, + + { id: 'rule_after_recent', type: 'element:divider' }, + + // ── 6. Assigned to them ─────────────────────────────────────────── + { + id: 'heading_assigned', + type: 'element:text', + properties: { + content: { en: 'Assigned to them', 'zh-CN': '他人指派' }, + variant: 'heading', + }, + }, + { + id: 'assigned_tasks', + type: 'record:related_list', + properties: { + objectName: 'duly_task', + relationshipField: 'owner', + title: 'Assigned work', + filter: [{ field: 'source', operator: 'equals', value: 'assigned' }], + // `assignment` is the fan-out this task came out of, and it is as + // close to "who assigned each" as this page can get: the assigner + // lives one hop further on (`duly_assignment.assigner`) and BOTH + // routes to it are closed — see gaps §4 and §6. The assignment's + // own name is one click from the answer, which is the honest + // remainder rather than a fake. + columns: ['subject', 'assignment', 'status', 'due_date'], + sort: [{ field: 'due_date', order: 'asc' }], + limit: 20, + }, + }, + + // ── Not a seventh section — a write surface being shut ──────────── + // This node exists to REPLACE the panel the host would otherwise + // append, not to add one. See gap §7: `RecordDetailView` appends a + // chatter panel with a comment box, reactions and threaded replies to + // every record page whose object does not opt out, `sys_user` cannot + // opt out (platform object, locked), and the renderer's own + // `disableDiscussion` escape is unauthorable. Declaring the component + // is what takes its configuration back. + // + // All three writes off. What remains is the record's own change + // history, which is read-only, is not the work log, and is not a + // comparison to anybody. A comment box on a person's record is worse + // than merely editable here — it is where a performance note would go, + // on a page whose entire premise is that the manager enters nothing. + { + id: 'member_history', + type: 'record:discussion', + properties: { + position: 'bottom', + collapsible: false, + feed: { + showCommentInput: false, + enableReactions: false, + enableThreading: false, + showFilterToggle: false, + showSubscriptionToggle: false, + }, + }, + }, + ], + }, + ], +}); diff --git a/src/translations/authored-text.ts b/src/translations/authored-text.ts index 3993035..d0214a2 100644 --- a/src/translations/authored-text.ts +++ b/src/translations/authored-text.ts @@ -161,6 +161,30 @@ const translate = (key: (ctx: KeyContext) => readonly string[] | undefined): Ver const id = (ctx: KeyContext): string | undefined => str(ctx.ids.name); +/** + * The `id` of the page component a string was found inside. + * + * A page nests its labels two levels below the surface root + * (`regions[i].components[j].properties.title`), so `ctx.parent` is the + * `properties` bag — which carries no identity. The addressable id is on the + * component, one level up, and the only way back to it is the concrete path: + * `ctx.ids` IS the page node for a `simpleSurfaces` surface, so walk it. + * + * Addressing by the authored `id` rather than by position is what keeps a + * bundle key stable when a component moves — reordering the page must not + * silently re-point every translation, which `regions.1.components.7` would. + */ +const pageComponentId = (ctx: KeyContext): string | undefined => { + const [regionsKey, regionIndex, componentsKey, componentIndex] = ctx.path; + if (regionsKey !== 'regions' || componentsKey !== 'components') return undefined; + const regions = ctx.ids.regions; + if (!Array.isArray(regions)) return undefined; + const region = regions[Number(regionIndex)]; + if (!isRec(region) || !Array.isArray(region.components)) return undefined; + const component = region.components[Number(componentIndex)]; + return isRec(component) ? str(component.id) : undefined; +}; + /** * Subtrees the walk does not descend into, each with the reason. An opaque * subtree holds machine values only — a field payload, a filter, a binding @@ -200,6 +224,18 @@ const OPAQUE: Readonly> = { 'permissionSet.objects': 'per-object CRUD scopes', 'permissionSet.fieldPermissions': 'per-field read/write flags', 'permissionSet.tabPermissions': 'per-app tab visibility', + // ── page ────────────────────────────────────────────────────────────── + // The same three value bags `view.*` above declares opaque, at their + // page-component position. A related list's `columns` / `sort` / `filter` + // carry exactly what a view's do — field paths, directions, operators, + // stored values and `{date-macros}` — and the prose net below is the + // backstop if one of them ever grows a sentence. + 'page.regions[].components[].properties.columns': 'column list — field paths', + 'page.regions[].components[].properties.sort': 'sort list — field paths and directions', + 'page.regions[].components[].properties.filter': + 'related-list filter rules — field paths, operators and machine values / date macros', + 'page.regions[].components[].responsiveStyles': + 'per-breakpoint CSS declarations (ADR-0065 scoped styles) — property names and values', }; /** @@ -312,6 +348,7 @@ const VERDICTS: Readonly> = { 'app.navigation[].children[].objectName': machine('bound object'), 'app.navigation[].children[].viewName': machine('bound view'), 'app.navigation[].children[].dashboardName': machine('bound dashboard'), + 'app.navigation[].children[].requiresObject': machine('capability gate — the runtime object name this entry needs registered'), // ── dashboard ───────────────────────────────────────────────────────── 'dashboard.name': machine('dashboard name'), @@ -332,6 +369,61 @@ const VERDICTS: Readonly> = { 'dashboard.widgets[].values[]': machine('dataset measure names'), 'dashboard.widgets[].colorVariant': machine('tile colour role'), + // ── page ────────────────────────────────────────────────────────────── + 'page.name': machine('page name — its routing identity'), + 'page.icon': machine('icon name'), + 'page.type': machine('page kind'), + 'page.kind': machine('page override mode'), + 'page.template': machine('layout template name'), + 'page.object': machine('bound object'), + 'page.label': translate((c) => (id(c) ? ['pages', id(c)!, 'label'] : undefined)), + 'page.description': translate((c) => (id(c) ? ['pages', id(c)!, 'description'] : undefined)), + 'page.regions[].name': machine('layout region name'), + 'page.regions[].width': machine('layout region width'), + 'page.regions[].components[].id': machine('component id — the translation key itself'), + 'page.regions[].components[].type': machine('component kind'), + 'page.regions[].components[].properties.objectName': machine('bound object'), + 'page.regions[].components[].properties.relationshipField': machine('field on the child object holding this record'), + 'page.regions[].components[].properties.layout': machine('highlight strip orientation'), + 'page.regions[].components[].properties.position': machine('panel dock position'), + 'page.regions[].components[].properties.variant': machine('text style variant'), + // `element:text`'s `content` is the ONE string that component renders, and + // `PageTranslation.components` has no key for it — measured on + // `@objectstack/spec` 17.2.0: the face is + // `title | description | label | placeholder | emptyText | submitLabel`, + // its alias table maps `text → label` and `caption → description`, and + // `content` appears nowhere in either. The face's own comment says the keys + // were derived from the copy props components declare, so this is a missed + // one rather than a deliberate exclusion (the two exclusions it DOES make + // are named there: `help` and `subtitle`). + // + // The route that works is the other one the same comment blesses: + // `content` is `I18nLabelSchema`, which accepts an inline `{ en, 'zh-CN' }` + // locale map resolved by `pickLocalized` at render — the route the + // platform's own `sys-user.page.ts` uses for its eight `element:text` + // nodes. So these strings ARE localized; they are localized at the + // authoring site instead of in the bundle, which is why they are excused + // here rather than keyed. Filed upstream; see the PR body. When the key + // lands, replace these two verdicts with a `translate` on + // `…properties.content` and move the copy into the bundles. + 'page.regions[].components[].properties.content.en': + untranslatable('`element:text` copy, localized inline — the bundle has no `content` key for this component'), + 'page.regions[].components[].properties.content.zh-CN': + untranslatable('`element:text` copy, localized inline — the bundle has no `content` key for this component'), + 'page.regions[].components[].properties.fields[].name': machine('field name'), + // The two strings a page component actually SHOWS. Both live under + // `properties` because that is where the renderer reads them from — a + // component's own top-level `label` is not what `record:related_list` draws + // as its heading, nor what `element:text` renders as its body. + 'page.regions[].components[].properties.title': translate((c) => { + const component = pageComponentId(c); + return id(c) && component ? ['pages', id(c)!, 'components', component, 'title'] : undefined; + }), + 'page.regions[].components[].properties.content': translate((c) => { + const component = pageComponentId(c); + return id(c) && component ? ['pages', id(c)!, 'components', component, 'content'] : undefined; + }), + // ── action ──────────────────────────────────────────────────────────── // An object-bound action is addressed under its object; an object-less one // under `globalActions`. `validateTranslationReferences` refuses the wrong diff --git a/src/translations/zh-CN.ts b/src/translations/zh-CN.ts index cf2b1b9..794d86d 100644 --- a/src/translations/zh-CN.ts +++ b/src/translations/zh-CN.ts @@ -464,6 +464,7 @@ export const dulyChinese = defineTranslationBundle({ nav_board: { label: '看板' }, group_team: { label: '团队' }, nav_duty_health: { label: '职责健康度' }, + nav_people: { label: '成员' }, nav_late: { label: '逾期' }, nav_stalled: { label: '停滞' }, nav_assignments: { label: '指派' }, @@ -478,6 +479,30 @@ export const dulyChinese = defineTranslationBundle({ }, }, + pages: { + duly_member: { + // 「看全貌」页:主管打开一个人,不必先问他任何问题。 + label: '成员', + description: + '一个人的全貌:当下有什么未完成、本周期应尽什么、长期持有什么、最近动了什么——只读,不需要任何人录入。', + components: { + // 岗位来自 `sys_user_position` 联结表,不是 `sys_user` 上的字段。 + member_position: { title: '岗位' }, + right_now_open: { title: '未完成' }, + // 逾期 = 过了派发当时该职责给出的宽限期,且仍未完成。 + right_now_late: { title: '逾期' }, + // 与「停滞」视图同名:同一个判断在产品里只能有一种说法。 + right_now_stalled: { title: '停滞' }, + // 「受治理」= 来自岗位职责库或主管指派;自行申报的不在此列。 + this_period_duties: { title: '受治理的职责' }, + // ⛔ 不要写成「常设任务」:常设职责永不产生任务,也永不完成。 + standing_duties: { title: '长期持有' }, + recent_activity: { title: '最近触碰' }, + assigned_tasks: { title: '被指派的工作' }, + }, + }, + }, + dashboards: { duly_duty_health: { label: '职责健康度', diff --git a/test/i18n-coverage.test.ts b/test/i18n-coverage.test.ts index 256a053..f3faf16 100644 --- a/test/i18n-coverage.test.ts +++ b/test/i18n-coverage.test.ts @@ -267,6 +267,8 @@ describe('untranslatable display text is declared rather than dropped', () => { 'job.description', 'job.label', 'object.validations[].message', + 'page.regions[].components[].properties.content.en', + 'page.regions[].components[].properties.content.zh-CN', 'permissionSet.description', 'permissionSet.label', 'position.description', @@ -306,6 +308,12 @@ describe('untranslatable display text is declared rather than dropped', () => { // checked below — so this is a gap that CLOSED, pinned at zero so it // cannot silently reopen as inline copy. expect(count('flow.nodes[].config.'), 'inline notification copy — closed by #69').toBe(0); + // `element:text` copy on `duly_member`, which IS end-user-facing and IS + // localized — inline, because `PageTranslation.components` has no + // `content` key (see the verdict's own note). Eight nodes × two locales. + // The number is the size of the filed spec gap; when the key lands these + // strings move into the bundles and this drops to 0. + expect(count('page.regions'), '`element:text` copy localized inline').toBe(16); }); }); diff --git a/test/member-page.test.ts b/test/member-page.test.ts new file mode 100644 index 0000000..364a54a --- /dev/null +++ b/test/member-page.test.ts @@ -0,0 +1,470 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { readFileSync } from 'node:fs'; + +import { describe, expect, it } from 'vitest'; + +import { MemberPage } from '../src/pages/member.page.js'; +import { dulyPages } from '../src/pages/index.js'; +import { dulyApps } from '../src/apps/index.js'; +import { Assignment, Duty, Task } from '../src/objects/index.js'; +import { ManagerPermissionSet, MemberPermissionSet, AdminPermissionSet } from '../src/security/index.js'; + +/** + * `duly_member` — the member detail page. + * + * ── These assert the AUTHORED page, never resolved rows ────────────────── + * Same reason `test/security.test.ts` gives, and it is not a convenience: every + * list on this page reads `duly_task` / `duly_duty`, both `sharingModel: + * 'private'`, both granted to managers at `readScope: 'unit_and_below'`. This + * checkout runs the OPEN edition — `@objectstack/security-enterprise` is absent + * — so those depths fall back to owner-only and every one of these lists is + * empty for anyone but themselves. A test that booted a kernel and counted rows + * would be measuring the edition, and it would keep passing on the day somebody + * deleted a filter. What a page IS, is its declaration; so this walks it. + * + * ── What each group is actually pinning ────────────────────────────────── + * The four "not on this page" rules from the card are product invariants, and + * each fails SILENTLY if broken: a log-entry count reads as a helpful number, a + * "+ New" button reads as a feature, a peer comparison reads as a dashboard. + * None of them turns anything red on its own. That is what this file is for. + */ + +/** + * The member page's own SOURCE TEXT, comments included — see the grep below for + * why the text and not the parsed object. + * + * `node:fs` rather than Vite's `?raw`, which also works: `test/node-builtins.d.ts` + * already declares `readFileSync` narrowly for `test/import-samples.test.ts`, and + * one way to read a file in a test beats two. Its `URL` overload is what makes + * `node:url` unnecessary here. + */ +const PAGE_SOURCE = readFileSync(new URL('../src/pages/member.page.ts', import.meta.url), 'utf8'); + +type Rec = Record; + +const page = MemberPage as unknown as Rec; +const regions = page.regions as Array<{ name: string; components: Rec[] }>; +const components: Rec[] = regions.flatMap((region) => region.components); +const props = (component: Rec): Rec => (component.properties ?? {}) as Rec; +const byId = (id: string): Rec => { + const found = components.find((component) => component.id === id); + if (!found) throw new Error(`no component with id ${id} on duly_member`); + return found; +}; +/** Every `record:related_list` on the page, in reading order. */ +const relatedLists = components.filter((component) => component.type === 'record:related_list'); +/** A filter rule's value, by field, on one related list. */ +const ruleFor = (component: Rec, field: string): Rec | undefined => + ((props(component).filter as Rec[] | undefined) ?? []).find((rule) => rule.field === field); + +// ─── It is wired, and it is the shape that carries record context ──────── + +describe('duly_member — wiring', () => { + it('is in the pages barrel', () => { + // A page not in its own barrel is dead metadata: it type-checks, it reads + // as wired, and the runtime never loads it (AGENTS.md rule 2). + expect(dulyPages).toContain(MemberPage); + }); + + it('is a record page bound to sys_user', () => { + // The binding is the whole design. `record:related_list` is the only + // authorable component that scopes itself to the record in context, and it + // only has a context on a `type: 'record'` page. + expect(page.type).toBe('record'); + expect(page.object).toBe('sys_user'); + }); + + it('is `full`, not `slotted` — the synthesizer would add the work log', () => { + // Not a style preference. A slotted page falls through to + // `buildDefaultPageSchema`, whose `tabs` synthesizer emits one related list + // per object holding an FK to `sys_user` — which on this stack includes + // `duly_log_entry.owner`. The one object this page must never show is the + // one the default layout adds for free. + expect(page.kind).toBe('full'); + expect(page.slots).toBeUndefined(); + expect(regions.length).toBeGreaterThan(0); + }); + + it('does not claim to be the default page for sys_user', () => { + // `@objectstack/platform-objects` ships `sys_user_detail` with + // `isDefault: true` for this same object, and `usePageAssignment` breaks + // the tie on a `priority` key `PageSchema` does not declare — so an author + // cannot write the tiebreaker the renderer reads. Two pages both claiming + // the default would make the flag a lie on whichever one loses; this one + // does not claim it. See the page's gap §5. + expect(page.isDefault).toBe(false); + }); + + it('is reachable from the Team nav group', () => { + const app = (dulyApps as Rec[])[0] as { navigation: Array }; + const team = app.navigation.find((group) => group.id === 'group_team'); + expect(team, 'the Team nav group').toBeDefined(); + const people = (team!.children ?? []).find((item) => item.id === 'nav_people'); + expect(people, 'the People entry that opens a person').toBeDefined(); + // A record page is reached by opening a RECORD, so the entry is the people + // list. `requiresObject` is what lets nav name a runtime-provided object at + // all — without it `defineStack` refuses the whole stack, because the + // cross-reference check resolves `objectName` against `config.objects` only. + expect(people!.type).toBe('object'); + expect(people!.objectName).toBe('sys_user'); + expect(people!.requiresObject).toBe('sys_user'); + }); + + it('every component carries a stable id', () => { + // Bundle keys are `pages.duly_member.components..title`. A component + // without an id is addressed by nothing, so its title can never be + // translated — and the i18n gate cannot see the hole, because a key that + // was never derived is not a key that went missing. + expect(components.filter((component) => typeof component.id !== 'string')).toEqual([]); + const ids = components.map((component) => component.id); + expect(new Set(ids).size, 'two components share an id').toBe(ids.length); + }); +}); + +// ─── ⛔ The work log is not on this page, in any form ───────────────────── + +describe('duly_member — `duly_log_entry` is absent', () => { + it('is not named anywhere in the source', () => { + // A GREP, deliberately, and not a walk of the parsed page. The rule is not + // "no list is bound to it" — it is that a manager must not learn the log + // exists. A count, a `visibleWhen` mentioning it, a comment promising to + // add it later: all of them are how the next author decides it is fine. + // `readScope: 'own'` on the object is the enforcement; this is the promise. + expect(PAGE_SOURCE.includes('duly_log_entry')).toBe(false); + }); + + it('binds no component to it', () => { + // The parsed half of the same rule, so a renamed object or a computed + // string cannot slip past the grep above. + const objects = components + .map((component) => props(component).objectName) + .filter((name): name is string => typeof name === 'string'); + expect(objects).not.toContain('duly_log_entry'); + // And positively: the page reads these three and nothing else. + expect([...new Set(objects)].sort()).toEqual(['duly_duty', 'duly_task', 'sys_user_position']); + }); +}); + +// ─── ⛔ Nothing on the page is editable (the authorable half) ───────────── + +describe('duly_member — read-only', () => { + /** + * The component types that write. `record:details` is on the list because its + * `inlineEdit` defaults ON wherever the object itself is editable — declaring + * the component is enough to make the page editable, no key required. + */ + const WRITING_TYPES = [ + 'record:details', + 'record:quick_actions', + 'element:form', + 'element:button', + 'element:text_input', + 'element:record_picker', + ]; + + it('declares no component type that writes', () => { + const offenders = components + .map((component) => String(component.type)) + .filter((type) => WRITING_TYPES.includes(type)); + expect(offenders).toEqual([]); + }); + + it('shuts the discussion panel rather than letting the host append one', () => { + // `record:chatter` / `record:discussion` is NOT on the list above, and that + // is the whole subtlety of gap §7. `RecordDetailView` appends a chatter + // panel — comment box, reactions, threaded replies, all hard-coded on — to + // every record page whose object does not set `enable.feeds: false`. + // `sys_user` is a locked platform object so that switch is not ours, and + // the renderer's own `disableDiscussion` opt-out is not a `PageSchema` key. + // Declaring the component is the ONLY way to own its configuration, so the + // page declares exactly one, with every write off. Removing this node does + // not remove the panel — it hands it back to the host with the writes ON, + // which is why this is pinned as a positive assertion and not an absence. + const discussions = components.filter((component) => + component.type === 'record:discussion' || component.type === 'record:chatter'); + expect(discussions.length, 'exactly one, or the host appends its own').toBe(1); + const feed = (props(discussions[0]!).feed ?? {}) as Rec; + expect(feed.showCommentInput, 'a comment box on a person\'s record').toBe(false); + expect(feed.enableReactions, 'a reaction is a write').toBe(false); + expect(feed.enableThreading, 'a reply is a write').toBe(false); + }); + + it('offers no add-existing picker and no row actions', () => { + // The two write affordances a related list CAN be told not to offer. + for (const list of relatedLists) { + expect(props(list).add, `${String(list.id)} declares an Add picker`).toBeUndefined(); + expect(props(list).actions, `${String(list.id)} declares row actions`).toBeUndefined(); + } + }); + + it('marks every highlight chip readonly', () => { + // `readonly: true` is the enforced half: `HeaderHighlight` refuses inline + // editing on a chip carrying it. A bare field name would be editable. + const highlights = components.filter((component) => component.type === 'record:highlights'); + expect(highlights.length).toBe(1); + const fields = props(highlights[0]!).fields as Array; + expect(fields.length).toBeGreaterThan(0); + for (const field of fields) { + expect(field.readonly, `${String(field.name)} is an editable chip`).toBe(true); + } + }); + + it('records the residue it cannot close — the host-resolved "+ New"', () => { + // NOT a pass for the gap: a pin on the page's own account of it, so the + // note cannot be deleted while the gap is open. A related list's create / + // edit / delete affordances are resolved by the host from the CHILD + // object's `userActions` and the principal's grant, and `duly_task` grants + // `allowCreate` to every Duly position — so the page can be shown a "+ New" + // it has no key to decline. When a page-level switch lands, this assertion + // is what tells the next author to use it and delete the note. + expect(MemberPermissionSet.objects?.duly_task?.allowCreate).toBe(true); + expect(ManagerPermissionSet.objects?.duly_task?.allowCreate).toBe(true); + expect(AdminPermissionSet.objects?.duly_task?.allowCreate).toBe(true); + expect(PAGE_SOURCE).toContain('§3 — A page cannot declare its related lists read-only'); + }); +}); + +// ─── ⛔ No comparison to other people ───────────────────────────────────── + +describe('duly_member — no comparison to peers', () => { + it('renders no aggregate element at all', () => { + // `element:number` is the only component that renders a computed figure, + // and it cannot be scoped to the record anyway (gap §1) — so one here would + // be counting the whole org beside this person's name, which is the + // comparison the card forbids, arrived at by accident. + const aggregates = components.filter((component) => + String(component.type).startsWith('element:number') + || String(component.type) === 'object-metric'); + expect(aggregates).toEqual([]); + }); + + it('binds nothing to another person', () => { + // Every data-bearing component is scoped to THIS record through + // `relationshipField`, and none carries a filter naming a user — no + // `{current_user_id}` (that is the VIEWER, not the person being read), no + // hard-coded owner, no unit-wide slice to rank this person inside. + for (const list of relatedLists) { + expect(props(list).relationshipField, `${String(list.id)} is unbound`).toBeTruthy(); + const rules = (props(list).filter as Rec[] | undefined) ?? []; + for (const rule of rules) { + expect( + JSON.stringify(rule.value).includes('current_user_id'), + `${String(list.id)} filters on the viewer, not the record`, + ).toBe(false); + } + } + }); +}); + +// ─── The six sections, in reading order ────────────────────────────────── + +describe('duly_member — content', () => { + it('reads in the order the card asks for', () => { + // Reading order IS the design: a manager scans down and stops when they + // have what they came for. Pinned as the sequence of data-bearing and + // heading components, so a reordering is a deliberate edit rather than a + // diff nobody notices. + const spine = components + .filter((component) => component.type !== 'element:divider') + .map((component) => String(component.id)); + expect(spine).toEqual([ + // 1. Header — who this is + 'member_identity', + 'member_position', + // 2. Right now + 'heading_right_now', + 'right_now_note', + 'right_now_open', + 'right_now_late', + 'right_now_stalled', + // 3. This period + 'heading_this_period', + 'this_period_note', + 'this_period_duties', + // 4. Standing duties + 'heading_standing', + 'standing_note', + 'standing_duties', + // 5. Recent activity + 'heading_recent', + 'recent_activity', + // 6. Assigned to them + 'heading_assigned', + 'assigned_tasks', + // Not a section — the shut discussion panel (gap §7). + 'member_history', + ]); + }); + + it('heads the page with the person, not with their numbers', () => { + const identity = byId('member_identity'); + expect(identity.type).toBe('record:highlights'); + expect((props(identity).fields as Rec[]).map((field) => field.name)) + .toEqual(['name', 'primary_business_unit_id', 'manager_id']); + // Position is the fourth thing the header owes and is NOT a column on + // `sys_user`: it is assigned through the `sys_user_position` junction, + // whose `position` holds `sys_position.name`. A junction is a related list + // or it is nothing. + const position = byId('member_position'); + expect(props(position).objectName).toBe('sys_user_position'); + expect(props(position).relationshipField).toBe('user_id'); + }); + + it('asks `status` and dates directly, never a stored flag', () => { + // AGENTS.md rule 5. There is no `is_late` / `is_open` to filter on, and a + // filter naming a formula field silently matches nothing — so the three + // "Right now" lenses have to be built out of stored, indexed columns. + const stored = new Set(Object.keys(Task.fields)); + for (const list of relatedLists.filter((l) => props(l).objectName === 'duly_task')) { + for (const rule of ((props(list).filter as Rec[] | undefined) ?? [])) { + expect(stored.has(String(rule.field)), `${String(rule.field)} is not a stored column`).toBe(true); + } + } + }); + + it('measures "late" against the grace the duty granted, like the `late` view', () => { + // One person must not be late on this page and on time on the Late list. + // Both read `late_after` — `due_date + duty.grace_days` stamped once at + // dispatch — which is what #48 settled. + const late = byId('right_now_late'); + expect(ruleFor(late, 'late_after')).toEqual({ + field: 'late_after', + operator: 'less_than', + value: '{today}', + }); + expect(ruleFor(late, 'status')?.value).toEqual(['open', 'in_progress']); + // And it SHOWS the column it judged on: a list that will not tell you why + // it thinks a task is late is the complaint #48 was about. + expect(props(late).columns).toContain('late_after'); + }); + + it('calls stagnation the same fortnight the `stalled` view does', () => { + const stalled = byId('right_now_stalled'); + expect(ruleFor(stalled, 'last_update_at')).toEqual({ + field: 'last_update_at', + operator: 'less_than', + value: '{14_days_ago}', + }); + expect(ruleFor(stalled, 'status')?.value).toEqual(['open', 'in_progress']); + }); + + it('emphasises the third number, which is the one that fires early', () => { + // "Not moving" is the only one of the three that warns before a deadline + // exists, so it is the one the card asks to be emphasised. ADR-0065 scoped + // styles, because an authored Tailwind `className` produces no CSS on a + // metadata page — the build-time Tailwind never scans runtime metadata. + const stalled = byId('right_now_stalled'); + expect(stalled.responsiveStyles, 'the emphasis was dropped').toBeTruthy(); + expect(byId('right_now_open').responsiveStyles).toBeUndefined(); + expect(byId('right_now_late').responsiveStyles).toBeUndefined(); + }); + + it('counts only GOVERNED duties for the period, never self-declared ones', () => { + // `source` is the caliber column, and `self` is the person's own + // record-keeping — surfaced to them, never scored or read up the line. + const period = byId('this_period_duties'); + expect(props(period).objectName).toBe('duly_duty'); + expect(props(period).relationshipField).toBe('owner'); + expect(ruleFor(period, 'source')?.value).toEqual(['catalog', 'assigned']); + expect(ruleFor(period, 'form')?.value).toBe('recurring'); + // Frequency leads and sorts, which is as close to "grouped by frequency" as + // `record:related_list` gets — it has no `groupBy` (gap §2). If that ever + // becomes authorable, this is the assertion that should change. + expect((props(period).columns as string[])[0]).toBe('frequency'); + expect((props(period).sort as Rec[])[0]).toEqual({ field: 'frequency', order: 'asc' }); + }); + + it('lists standing duties from `form`, not through tasks, and offers no tick', () => { + const standing = byId('standing_duties'); + expect(props(standing).objectName).toBe('duly_duty'); + expect(ruleFor(standing, 'form')).toEqual({ field: 'form', operator: 'equals', value: 'standing' }); + // A standing duty generates no task BY CONSTRUCTION, so a task-side query + // would return nothing and read as "this person holds none". + expect(props(standing).objectName).not.toBe('duly_task'); + // `standing` is a real option on the object — a filter naming a value the + // select does not carry matches nothing, silently. + const forms = (Duty.fields.form as unknown as { options: Array<{ value: string }> }).options; + expect(forms.map((option) => option.value)).toContain('standing'); + // No completion affordance anywhere near them, and no `status` column + // either: `duly_duty.status` is active/paused/retired — lifecycle, not + // completion — and putting it beside these rows invites reading a state + // machine into work that has none. + expect(props(standing).columns).not.toContain('status'); + expect(props(standing).add).toBeUndefined(); + expect(props(standing).actions).toBeUndefined(); + }); + + it('shows the last 20 task events by `last_update_at`', () => { + const recent = byId('recent_activity'); + expect(props(recent).objectName).toBe('duly_task'); + expect(props(recent).limit).toBe(20); + expect(props(recent).sort).toEqual([{ field: 'last_update_at', order: 'desc' }]); + // No status filter: "what has moved" includes what moved into done, + // skipped and cancelled, which is what a manager is looking for. + expect(props(recent).filter).toBeUndefined(); + }); + + it('shows assigned work AND who assigned it', () => { + const tasks = byId('assigned_tasks'); + expect(ruleFor(tasks, 'source')).toEqual({ field: 'source', operator: 'equals', value: 'assigned' }); + // `assigned` is a real option on `duly_task.source`. + const sources = (Task.fields.source as unknown as { options: Array<{ value: string }> }).options; + expect(sources.map((option) => option.value)).toContain('assigned'); + + // …and the `assignment` column, which is as close to "who assigned each" + // as this page can get. Both routes to `duly_assignment.assigner` are + // closed: a related-list column cannot cross a lookup (gap §4), and a + // related list bound on `assignees` is refused by the driver, because + // `RelatedList` builds its parent filter as bare equality and `assignees` + // is `multiple: true` (gap §6 — measured, 400 INVALID_FILTER). + expect(props(tasks).columns).toContain('assignment'); + expect(Object.keys(Assignment.fields)).toContain('assigner'); + expect((Assignment.fields.assignees as unknown as { multiple?: boolean }).multiple).toBe(true); + // The pin on the gap: no list on this page binds a multi-value field, and + // the page says why. Delete this when a membership binding lands. + for (const list of relatedLists) { + expect(props(list).relationshipField, 'a related list bound to a multi-value field') + .not.toBe('assignees'); + } + expect(PAGE_SOURCE).toContain('§6 — `record:related_list` cannot bind to a multi-value field'); + }); +}); + +// ─── Every binding names something real ────────────────────────────────── + +describe('duly_member — no dangling binding', () => { + const DECLARED: Record> = { + duly_task: new Set(Object.keys(Task.fields)), + duly_duty: new Set(Object.keys(Duty.fields)), + duly_assignment: new Set(Object.keys(Assignment.fields)), + }; + + it('every column, filter field and sort key resolves on its own object', () => { + // The page half of what `test/metadata-bindings.test.ts` does for views and + // nav. Field paths are resolved at author time NOWHERE in the UI layer, so + // `pnpm validate` and `pnpm build` both exit 0 on a misspelt related-list + // column — it renders an empty column and reports success. + // + // `sys_user_position` is a PLATFORM object: nothing on disk carries its + // field set, so its bindings are a boundary rather than a resolution and + // are deliberately not judged here (the same narrowing `metadata-bindings` + // states for a hop into `sys_user`). + const findings: string[] = []; + for (const list of relatedLists) { + const object = String(props(list).objectName); + const fields = DECLARED[object]; + if (!fields) continue; + const referenced: string[] = [ + ...((props(list).columns as string[] | undefined) ?? []), + ...((props(list).filter as Rec[] | undefined) ?? []).map((rule) => String(rule.field)), + ...((props(list).sort as Rec[] | undefined) ?? []).map((rule) => String(rule.field)), + String(props(list).relationshipField), + ]; + for (const field of referenced) { + if (!fields.has(field)) findings.push(`${String(list.id)} · ${object}.${field}`); + } + } + expect(findings, 'a related-list binding that names no declared field').toEqual([]); + }); +});