From 7099555bbbbb406153f05780ef72f9301b4b5923 Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 05:25:11 +0000 Subject: [PATCH 1/2] Add board, schedule, recent, by_unit and catalog_tree lenses Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- src/apps/duly.app.ts | 10 ++++ src/views/duty.view.ts | 42 +++++++++++++ src/views/task.view.ts | 131 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) diff --git a/src/apps/duly.app.ts b/src/apps/duly.app.ts index 17e6792..7fbf9e2 100644 --- a/src/apps/duly.app.ts +++ b/src/apps/duly.app.ts @@ -28,6 +28,12 @@ export const DulyApp = App.create({ { id: 'nav_my_duties', type: 'object', objectName: 'duly_duty', viewName: 'mine', label: 'My duties', icon: 'clipboard-list' }, { id: 'nav_standing', type: 'object', objectName: 'duly_duty', viewName: 'standing', label: 'Standing duties', icon: 'anchor' }, { id: 'nav_log', type: 'object', objectName: 'duly_log_entry', label: 'Work log', icon: 'notebook-pen' }, + // The board lives HERE, not under Team, and that placement is a + // product rule rather than taste: dragging a card writes `status`, and + // managers do not enter status — assigning is their only write. A + // drag-to-done surface in the manager's section would invite exactly + // the write the model refuses them. + { id: 'nav_board', type: 'object', objectName: 'duly_task', viewName: 'board', label: 'Board', icon: 'kanban' }, ], }, { @@ -39,6 +45,9 @@ export const DulyApp = App.create({ { 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' }, + { id: 'nav_schedule', type: 'object', objectName: 'duly_task', viewName: 'schedule', label: 'Schedule', icon: 'gantt-chart' }, + { id: 'nav_recent', type: 'object', objectName: 'duly_task', viewName: 'recent', label: 'Recent activity', icon: 'history' }, + { id: 'nav_by_unit', type: 'object', objectName: 'duly_task', viewName: 'by_unit', label: 'By business unit', icon: 'building-2' }, ], }, { @@ -49,6 +58,7 @@ export const DulyApp = App.create({ children: [ { id: 'nav_catalog', type: 'object', objectName: 'duly_catalog_item', label: 'Role catalog', icon: 'list-checks' }, { id: 'nav_all_duties', type: 'object', objectName: 'duly_duty', label: 'All duties', icon: 'library' }, + { id: 'nav_catalog_tree', type: 'object', objectName: 'duly_duty', viewName: 'catalog_tree', label: 'What each team owes', icon: 'folder-tree' }, ], }, ], diff --git a/src/views/duty.view.ts b/src/views/duty.view.ts index 7238660..44e9de2 100644 --- a/src/views/duty.view.ts +++ b/src/views/duty.view.ts @@ -43,5 +43,47 @@ export const DutyViews = defineView({ columns: [{ field: 'name' }, { field: 'owner' }, { field: 'business_unit' }, { field: 'status' }], filter: [{ field: 'form', operator: 'equals', value: 'standing' }], }, + + /** + * "What does this team owe" — every duty bucketed by business unit, then + * by the one accountable person inside it, both levels collapsible. + * + * ⚠️ DELIBERATELY `type: 'grid'` + `grouping`, NOT `type: 'tree'`, and the + * difference is not cosmetic. The platform's `tree` view is a + * SELF-REFERENCING hierarchy: `TreeConfigSchema` takes a `parentField` + * single-parent pointer, and the renderer nests a record under another + * record of the SAME object by matching `record[parentField]` against the + * sibling ids. `duly_duty` has no self-reference — its lookups point at + * `sys_business_unit`, `duly_catalog_item` and a user — so a `type: 'tree'` + * view here resolves no parent for any row, puts every duty at depth 0 and + * renders a FLAT list. The renderer never reads `grouping`, so declaring + * the two levels alongside it would change nothing, and nothing in + * `pnpm validate` says so: the `view/layout-without-binding` gate does not + * cover `tree` at all. That is the exact "renders wrong while authoring + * reports success" shape this app's views exist to avoid, so it is not + * shipped and the gap is filed upstream instead (see the PR body). + * + * The grid's grouping hook is the honest expression of the same idea and + * it is measured, not assumed: it recurses through `grouping.fields`, + * building nested `subgroups` with per-level collapse — a real two-level + * hierarchy. Group keys sort by LABEL, never by bucket size, so no team + * and no person is ever ordered by how much they owe. + */ + catalog_tree: { + label: 'What each team owes', + type: 'grid', + data, + columns: [ + { field: 'name' }, + { field: 'form' }, + { field: 'frequency' }, + { field: 'source' }, + { field: 'status' }, + ], + grouping: { + fields: [{ field: 'business_unit' }, { field: 'owner' }], + }, + sort: [{ field: 'name', order: 'asc' }], + }, }, }); diff --git a/src/views/task.view.ts b/src/views/task.view.ts index 159d806..3d6d51e 100644 --- a/src/views/task.view.ts +++ b/src/views/task.view.ts @@ -161,5 +161,136 @@ export const TaskViews = defineView({ }, sort: [{ field: 'due_date', order: 'asc' }], }, + + /** + * Kanban. Columns come from `duly_task.status` — the renderer reads the + * field's own `options` for the column set, their order and their labels + * (measured in the console's `plugin-kanban`), so the board cannot drift + * from the object. + * + * Dragging a card writes ONE field. Measured against the renderer's move + * handler: `dataSource.update(object, id, { [groupBy]: toColumnId })` — + * `status` and nothing else, which is the same write the row action does. + * It is NOT gated on `inlineEdit`; it is gated on update permission, so a + * user who may not write `status` gets the platform's rejection rather + * than a silent no-op. + * + * `inlineEdit` is still declared, and it is not decoration: the toolbar + * lets a viewer switch this view to its grid visualisation, and the + * adapter honours `inlineEdit` on that branch (`editable:` is set in the + * grid case only). On the kanban branch itself it is inert. + * + * No `summarizeField`: it renders a per-column SUM, and there is no number + * on a task worth totalling. The nearest candidate would be a count, and + * counts are never ranked or compared here. + */ + board: { + label: 'Board', + type: 'kanban', + data, + columns: [{ field: 'subject' }, { field: 'due_date' }, { field: 'owner' }, { field: 'source' }], + kanban: { + groupByField: 'status', + // The card face, in reading order: what it is, when it is owed, whose + // it is, and where it came from. + columns: ['subject', 'due_date', 'owner', 'source'], + }, + inlineEdit: true, + sort: [{ field: 'due_date', order: 'asc' }], + }, + + /** + * Gantt. The bar runs `visible_from` → `due_date`, and that span IS the + * lead time: a bar that starts on its own due date is a task that first + * appeared the day it was already owed, which is a report on a failure + * rather than a reminder. `lead_days` on the duty is what produces the + * length, and this is the only screen where you can see it. + * + * Grouped by owner so an overloaded fortnight is visible as one person's + * row going solid, before the period arrives. + * + * The filter is not a scope narrowing, it is a correctness one: the + * renderer maps a missing date to `new Date()`, so an undated one-off + * would draw a zero-width bar on TODAY and read as load that does not + * exist. Both columns are stored and indexed; the unary operators carry + * their direction in the name and take no value. + * + * No `colorField`, deliberately, and this is measured rather than an + * oversight: the gantt renderer passes `record[colorField]` straight into + * `backgroundColor`, so `colorField: 'status'` sets `background: "open"` — + * not a colour, silently dropped, every bar identical. With the key + * ABSENT the same renderer falls through to its status-derived palette and + * the bars separate by state. Filed upstream; see the PR body. + */ + schedule: { + label: 'Schedule', + type: 'gantt', + data, + columns: [ + { field: 'subject' }, + { field: 'status' }, + { field: 'owner' }, + { field: 'visible_from' }, + { field: 'due_date' }, + ], + gantt: { + startDateField: 'visible_from', + endDateField: 'due_date', + titleField: 'subject', + groupByField: 'owner', + viewMode: 'week', + tooltipFields: [{ field: 'status' }, { field: 'period_key' }, { field: 'source' }], + }, + filter: [ + { field: 'visible_from', operator: 'is_not_null' }, + { field: 'due_date', operator: 'is_not_null' }, + ], + sort: [{ field: 'visible_from', order: 'asc' }], + }, + + /** + * Timeline — the visual companion to `stalled`. Ordered by + * `last_update_at`, which `task.hook.ts` stamps on a status change or a + * note edit and deliberately does NOT advance on an administrative write, + * so this really is "what has been happening" and not "what has been + * touched". + * + * `colorField` earns its place here: the timeline renderer resolves it + * against the object's own `status` options and uses the AUTHORED colours + * (`#35674D` for done, and so on), so this lens and the status badge in + * every grid read the same. + */ + recent: { + label: 'Recent activity', + type: 'timeline', + data, + columns: [{ field: 'subject' }, { field: 'status' }, { field: 'owner' }, { field: 'last_update_at' }], + timeline: { + startDateField: 'last_update_at', + titleField: 'subject', + colorField: 'status', + scale: 'day', + }, + sort: [{ field: 'last_update_at', order: 'desc' }], + }, + + /** + * The same rows a manager already reads, bucketed by team. `business_unit` + * is denormalised onto the task at dispatch precisely so a rollup like + * this survives a later transfer. + * + * Groups sort by LABEL — measured in the grid's grouping hook, which sorts + * group keys with a locale compare on the label and never on the bucket + * size. Nothing here ranks a unit, or a person, by a count. + */ + by_unit: { + label: 'By business unit', + type: 'grid', + data, + columns, + grouping: { fields: [{ field: 'business_unit' }] }, + bulkActionDefs: bulkActions, + sort: [{ field: 'due_date', order: 'asc' }], + }, }, }); From 997782a4ce02ca3030b9b9cd3c9b07c7b7ae3f9c Mon Sep 17 00:00:00 2001 From: Warren Date: Tue, 1 Sep 2026 05:33:11 +0000 Subject: [PATCH 2/2] =?UTF-8?q?Add=20test/views.test.ts=20=E2=80=94=20bind?= =?UTF-8?q?ing,=20field-reference=20and=20nav-reachability=20stopgap=20plu?= =?UTF-8?q?s=20product=20pins?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p --- test/views.test.ts | 324 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 324 insertions(+) create mode 100644 test/views.test.ts diff --git a/test/views.test.ts b/test/views.test.ts new file mode 100644 index 0000000..ee973e5 --- /dev/null +++ b/test/views.test.ts @@ -0,0 +1,324 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { dulyApps } from '../src/apps/index.js'; +import { dulyObjects } from '../src/objects/index.js'; +import { dulyViews } from '../src/views/index.js'; + +/** + * View tests — two jobs, kept apart on purpose. + * + * ── 1. A STOPGAP for what `pnpm validate` does not see ─────────────────── + * ⚠️ Delete the stopgap half when the upstream rules land. It is not a house + * rule that wants maintaining forever; it is author-time coverage every + * ObjectStack app would otherwise re-implement, and it is written to be + * removed rather than kept in step with the platform: + * + * objectstack-ai/objectstack#14106 — `view/layout-without-binding` covers + * `kanban` / `calendar` / `gantt` only. `timeline`, `tree` and `map` have + * the same literal-default fallback in the renderer and no gate. + * objectstack-ai/objectstack#14107 — NO field reference on a list view is + * resolved at author time: not `columns`, `filter`, `sort`, `grouping`, + * nor any binding block. Neither `os validate` NOR `os build` sees it. + * objectstack-ai/objectstack#14108 — a nav `viewName` naming a view that + * does not exist silently opens the default view instead. + * + * All three were measured against this repo on `@objectstack/cli` 17.2.0 by + * mutating a view and re-running the gates. The readings are in the issues. + * + * ── 2. PRODUCT pins that outlive the platform gaps ─────────────────────── + * The gantt starting at `visible_from`, the timeline ordering by + * `last_update_at`, one colour source for status, nothing ordered by a count. + * Those stay when the stopgap goes: no upstream rule can know them. + */ + +type Rec = Record; + +const objectFields = new Map>( + (dulyObjects as unknown as Array<{ name: string; fields: Rec }>).map( + (o) => [o.name, new Set(Object.keys(o.fields))] as const, + ), +); + +/** + * Fields the platform provides on every object. A view may legitimately name + * one, and they are not in the authored `fields` map. + */ +const SYSTEM_FIELDS = new Set([ + 'id', 'created_at', 'updated_at', 'created_by', 'updated_by', 'owner_id', + 'organization_id', 'business_unit_id', +]); + +interface NamedView { + /** e.g. `duly_task › listViews.board` — the string an assertion failure prints. */ + where: string; + object: string; + view: Rec; +} + +/** Every list view in the stack, flattened, each tagged with its bound object. */ +const allViews: NamedView[] = []; +for (const entry of dulyViews as unknown as Array<{ list?: Rec; listViews?: Record }>) { + const push = (label: string, view: Rec | undefined) => { + if (!view) return; + const object = ((view.data as Rec | undefined)?.object as string | undefined) ?? '(no data.object)'; + allViews.push({ where: `${object} › ${label}`, object, view }); + }; + push('list', entry.list); + for (const [name, view] of Object.entries(entry.listViews ?? {})) push(`listViews.${name}`, view); +} + +const byName = (name: string): NamedView => { + const found = allViews.find((v) => v.where.endsWith(`listViews.${name}`)); + if (!found) throw new Error(`no list view named "${name}" — declared: ${allViews.map((v) => v.where).join(', ')}`); + return found; +}; + +const fieldOf = (col: unknown): string | undefined => + typeof col === 'string' ? col : typeof (col as Rec)?.field === 'string' ? (col as Rec).field as string : undefined; + +describe('every non-grid lens is bound to real fields', () => { + /** + * The binding block each view type needs, and the keys inside it that MUST + * be present for the view to render anything at all. Read off each block's + * Zod schema in `@objectstack/spec/ui` (`view.zod.ts`) — these are the keys + * declared without `.optional()`. + * + * The platform's own `view/layout-without-binding` gate knows only the first + * three rows (#14106). `timeline` and `tree` are here because this repo uses + * them and nothing else checks them. + */ + const REQUIRED_BINDINGS: Record = { + kanban: { block: 'kanban', keys: ['groupByField', 'columns'] }, + calendar: { block: 'calendar', keys: ['startDateField', 'titleField'] }, + gantt: { block: 'gantt', keys: ['startDateField', 'endDateField', 'titleField'] }, + timeline: { block: 'timeline', keys: ['startDateField', 'titleField'] }, + // `TreeConfigSchema`'s keys are all optional, so "has a block" is a weak + // assertion here — but a tree with no `parentField` on an object with no + // self-reference renders FLAT, not empty, which is why this repo does not + // author one at all (objectstack-ai/objectstack#14109). + tree: { block: 'tree', keys: [] }, + map: { block: 'map', keys: [] }, + }; + + it('declares the binding block its type needs', () => { + for (const { where, view } of allViews) { + const spec = REQUIRED_BINDINGS[view.type as string]; + if (!spec) continue; + const block = view[spec.block] as Rec | undefined; + expect( + block, + `${where} is a \`${view.type}\` view with no \`${spec.block}\` block — the renderer falls back to ` + + 'literal default field names and the view renders empty while authoring reports success', + ).toBeTypeOf('object'); + for (const key of spec.keys) { + expect(block?.[key], `${where}: \`${spec.block}.${key}\` is required for this view to render`).toBeTruthy(); + } + } + }); + + /** + * #14107: a misspelt field name anywhere on a view is parse-clean, publishes + * green, and renders blank. Resolve every one of them here instead. + */ + it('names only fields that exist on the object it is bound to', () => { + const bindingFieldKeys = [ + 'groupByField', 'summarizeField', 'startDateField', 'endDateField', 'titleField', + 'colorField', 'labelField', 'parentField', 'progressField', 'dependenciesField', + 'assigneeField', 'effortField', 'baselineStartField', 'baselineEndField', + 'locationField', 'latitudeField', 'longitudeField', 'coverField', 'allDayField', + ]; + + for (const { where, object, view } of allViews) { + const known = objectFields.get(object); + expect(known, `${where}: bound to "${object}", which no object in this stack defines`).toBeDefined(); + const check = (name: string | undefined, at: string) => { + if (!name || name.includes('.')) return; + expect( + known!.has(name) || SYSTEM_FIELDS.has(name), + `${where}: ${at} names "${name}", which is not a field on ${object}. ` + + `Fields: ${[...known!].sort().join(', ')}`, + ).toBe(true); + }; + + for (const col of (view.columns as unknown[]) ?? []) check(fieldOf(col), 'columns[]'); + for (const rule of (view.filter as Rec[]) ?? []) check(rule.field as string, 'filter[].field'); + if (Array.isArray(view.sort)) for (const s of view.sort as Rec[]) check(s.field as string, 'sort[].field'); + for (const g of ((view.grouping as Rec | undefined)?.fields as Rec[]) ?? []) { + check(g.field as string, 'grouping.fields[].field'); + } + check((view.rowColor as Rec | undefined)?.field as string, 'rowColor.field'); + + for (const spec of Object.values(REQUIRED_BINDINGS)) { + const block = view[spec.block] as Rec | undefined; + if (!block) continue; + for (const key of bindingFieldKeys) check(block[key] as string, `${spec.block}.${key}`); + for (const col of (block.columns as unknown[]) ?? []) check(fieldOf(col), `${spec.block}.columns[]`); + for (const f of (block.fields as unknown[]) ?? []) check(fieldOf(f), `${spec.block}.fields[]`); + for (const t of (block.tooltipFields as unknown[]) ?? []) check(fieldOf(t), `${spec.block}.tooltipFields[]`); + } + } + }); +}); + +describe('the lenses say what the product means', () => { + /** + * The whole argument for `lead_days` is visible here as bar LENGTH. A bar + * that starts on the due date is a task that first appeared the day it was + * already owed — a report on a failure, not a reminder. + */ + it('the gantt bar starts at visible_from and ends at due_date', () => { + const gantt = byName('schedule').view.gantt as Rec; + expect(gantt.startDateField).toBe('visible_from'); + expect(gantt.endDateField).toBe('due_date'); + expect( + gantt.startDateField, + 'start and end on the same column is a zero-length bar — lead time dropped', + ).not.toBe(gantt.endDateField); + }); + + /** + * `last_update_at` is hook-stamped on a status change or a note edit and + * deliberately does NOT advance on an administrative write, which is what + * makes this the companion to the stagnation number rather than a "recently + * touched by anything" list. + */ + it('the timeline reads last_update_at, newest first', () => { + const recent = byName('recent').view; + expect((recent.timeline as Rec).startDateField).toBe('last_update_at'); + expect(recent.sort).toEqual([{ field: 'last_update_at', order: 'desc' }]); + }); + + /** + * One source of truth for status colour: `duly_task.status`'s own + * `options[].color`. A view that hand-authors a colour map is a second + * source that drifts the moment an option is added. + */ + it('every lens colours from status, and no view carries its own colour map', () => { + for (const { where, view } of allViews) { + expect(view.rowColor, `${where}: a rowColor map is a second colour source — colour from the field`).toBeUndefined(); + expect(view.conditionalFormatting, `${where}: hand-authored row styling duplicates the status palette`).toBeUndefined(); + for (const block of ['kanban', 'calendar', 'gantt', 'timeline'] as const) { + const cfg = view[block] as Rec | undefined; + if (cfg?.colorField !== undefined) { + expect(cfg.colorField, `${where}: \`${block}.colorField\` must be "status"`).toBe('status'); + } + } + } + expect((byName('board').view.kanban as Rec).groupByField).toBe('status'); + }); + + /** + * ⚠️ Do NOT "fix" this by adding `colorField: 'status'` to the gantt. + * Measured on @objectstack/console 17.2.0: the gantt renderer puts + * `record[colorField]` straight into `backgroundColor`, so the key resolves + * to `background: "open"` — invalid CSS, dropped, EVERY BAR IDENTICAL. With + * the key absent the same renderer falls through to its status-derived + * palette and the bars separate by state. Filed as + * objectstack-ai/objectstack#14110; revisit when that lands. + */ + it('the gantt deliberately declares no colorField', () => { + expect(byName('schedule').view.gantt as Rec).not.toHaveProperty('colorField'); + }); + + /** Item counts are never ranked or compared — not as a sort, not as a total. */ + it('no view orders or totals anything by a count', () => { + for (const { where, view } of allViews) { + expect( + (view.kanban as Rec | undefined)?.summarizeField, + `${where}: a kanban column total is a number nobody asked for`, + ).toBeUndefined(); + for (const col of (view.columns as unknown[]) ?? []) { + const summary = (col as Rec)?.summary; + expect(summary, `${where}: a column footer aggregation puts a count in the UI`).toBeUndefined(); + } + for (const s of (Array.isArray(view.sort) ? view.sort : []) as Rec[]) { + expect( + String(s.field), + `${where}: sorting by a count ranks whoever the rows belong to`, + ).not.toMatch(/count|total|_num$/); + } + } + }); + + /** + * Lateness and stagnation are asked of stored, indexed columns. A stored + * flag needs a writer that runs every midnight; a formula field is virtual + * and a filter naming one silently matches nothing. + */ + it('no filter reaches for a derived flag', () => { + for (const { where, view } of allViews) { + for (const rule of (view.filter as Rec[]) ?? []) { + expect(String(rule.field), `${where}: filter on a flag that does not exist`).not.toMatch( + /^is_(late|overdue|open|completed)$/, + ); + } + } + }); +}); + +describe('navigation', () => { + interface NavItem { id?: string; type?: string; objectName?: string; viewName?: string; children?: NavItem[] } + + const navItems: NavItem[] = []; + const walk = (items: NavItem[] | undefined) => { + for (const item of items ?? []) { + navItems.push(item); + walk(item.children); + } + }; + for (const app of dulyApps as unknown as Array<{ navigation?: NavItem[] }>) walk(app.navigation); + + /** + * #14108: a nav entry naming a view that does not exist does not fail — it + * falls back to the default view, keeps its authored label, and looks right + * in review. This is the check that would have caught it. + */ + it('every nav entry resolves to a view that exists on the object it names', () => { + for (const item of navItems) { + if (item.type !== 'object' || !item.objectName) continue; + expect( + objectFields.has(item.objectName), + `nav "${item.id}" targets object "${item.objectName}", which this stack does not define`, + ).toBe(true); + if (!item.viewName) continue; // no viewName = the object's default list + const known = allViews + .filter((v) => v.object === item.objectName) + .map((v) => v.where.split('listViews.')[1]) + .filter(Boolean); + expect( + known, + `nav "${item.id}" opens viewName "${item.viewName}", which ${item.objectName} does not declare — ` + + 'the shell silently falls back to the default view', + ).toContain(item.viewName); + } + }); + + it('every lens this app adds is reachable from navigation', () => { + const reachable = new Set(navItems.map((i) => `${i.objectName}.${i.viewName}`)); + const expected = [ + ['duly_task', 'board'], + ['duly_task', 'schedule'], + ['duly_task', 'recent'], + ['duly_task', 'by_unit'], + ['duly_duty', 'catalog_tree'], + ] as const; + for (const [object, view] of expected) { + expect(reachable, `${object}.${view} has no nav entry — a view nobody can reach is dead metadata`) + .toContain(`${object}.${view}`); + } + }); + + /** + * Managers do not enter status; assigning is their only write. The board + * writes `status` on every card drag, so it belongs with the owner's own + * screens and not in the manager's section. + */ + it('the board sits under My work, not Team', () => { + const me = navItems.find((i) => i.id === 'group_me'); + expect(me?.children?.map((c) => c.id)).toContain('nav_board'); + const team = navItems.find((i) => i.id === 'group_team'); + expect(team?.children?.map((c) => c.id)).not.toContain('nav_board'); + }); +});