From b458e5aaed6005a25e8937e65927348aa5044b6f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:13:10 +0000 Subject: [PATCH 1/5] feat(spec): kanban config gains an optional ordered lane list, kanban.lanes Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxL3MYcTjfshb8eeK3 --- .changeset/28018-kanban-lanes.md | 17 ++++ packages/spec/liveness/view.json | 2 +- .../spec/src/ui/kanban-config-lanes.test.ts | 79 +++++++++++++++++++ packages/spec/src/ui/view.zod.ts | 67 +++++++++++++++- 4 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 .changeset/28018-kanban-lanes.md create mode 100644 packages/spec/src/ui/kanban-config-lanes.test.ts diff --git a/.changeset/28018-kanban-lanes.md b/.changeset/28018-kanban-lanes.md new file mode 100644 index 00000000000..f244d4827d0 --- /dev/null +++ b/.changeset/28018-kanban-lanes.md @@ -0,0 +1,17 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): a kanban board can choose which lanes it draws, and in what order, `kanban.lanes` + +Clause-②: yes (widening) +Metadata-protocol: added + +A board grouped on a select field drew one lane per option, always, so a lane the view's own filter keeps empty (a task board that filters out completed tasks still drawing its Completed lane) could not be left off, and the lanes could not be put in any order but the options' own. `KanbanConfigSchema` (the list view's `kanban` block) gains one optional key: + +- **`lanes: string[]`.** The lanes the board draws, in this order, as stored option values of the `groupByField` field (the option `value`, not its label). For example `lanes: ['not_started', 'in_progress', 'waiting']` on a task board grouped by `status`. +- **A choice of lanes, not a record filter.** A record whose value is not listed lands in the board's `Uncategorized` lane, as a record whose value matches no lane already does. To keep records off the board, filter the view. +- **Absent means one lane per option.** The key has no spec-side default, so a board stored before it existed parses to exactly the block it stored and draws the lanes it always did. +- **Refused at the key:** an empty list (omit the key instead), a value listed twice, and an entry that is not shaped like a stored option value (a display label such as `'In Progress'`). + +The Console renderer reads the key in a follow-up change; until then a board that declares it still draws one lane per option. diff --git a/packages/spec/liveness/view.json b/packages/spec/liveness/view.json index f5f02e9073d..dcf6ac1240b 100644 --- a/packages/spec/liveness/view.json +++ b/packages/spec/liveness/view.json @@ -101,7 +101,7 @@ }, "kanban": { "status": "live", - "note": "objectui: KanbanImpl.tsx:682 — field-by-field wired (audit L16). What `groupByField` names, on kanban, gantt and timeline alike (packages/spec/src/ui/view.zod.ts, the padded-spelling refusal; moved out of spec source, #23434): a lookup BY THAT NAME on data the server answers under the unpadded name, at objectui @dda8f3815 packages/plugin-list/src/ListView.tsx#laneField (`groupByField || groupField || detectStatusField(objectDef)`, inside #viewComponentSchema; cards bucketed by `card[laneField]`), objectui @dda8f3815 packages/plugin-gantt/src/ObjectGantt.tsx#groupByAccessor (splits on `.` and walks the record) and objectui @dda8f3815 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`item[groupByField]`)." + "note": "objectui: KanbanImpl.tsx:682 — field-by-field wired (audit L16). One key rides this blanket verdict at PLANNED strength, as `viewMode` does on the gantt row: `lanes` (the lanes the board draws, in order, as stored option values of `groupByField`; absent meaning one lane per option) is declared ahead of its reader — at this repo's `ui/` tree on 9f13ee8154, ui/packages/plugin-kanban/src/ObjectKanban.tsx#effectiveColumns still draws one lane per declared option of the group field and reads no lane list. Amend this note to plain live, citing that read, when the console half lands. What `groupByField` names, on kanban, gantt and timeline alike (packages/spec/src/ui/view.zod.ts, the padded-spelling refusal; moved out of spec source, #23434): a lookup BY THAT NAME on data the server answers under the unpadded name, at objectui @dda8f3815 packages/plugin-list/src/ListView.tsx#laneField (`groupByField || groupField || detectStatusField(objectDef)`, inside #viewComponentSchema; cards bucketed by `card[laneField]`), objectui @dda8f3815 packages/plugin-gantt/src/ObjectGantt.tsx#groupByAccessor (splits on `.` and walks the record) and objectui @dda8f3815 packages/plugin-timeline/src/ObjectTimeline.tsx#effectiveItems (`item[groupByField]`)." }, "calendar": { "status": "live", diff --git a/packages/spec/src/ui/kanban-config-lanes.test.ts b/packages/spec/src/ui/kanban-config-lanes.test.ts new file mode 100644 index 00000000000..acd09297b3f --- /dev/null +++ b/packages/spec/src/ui/kanban-config-lanes.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `kanban.lanes` — the lanes a board draws, in order, as stored option values + * of `groupByField`. + * + * Legs: the key is a MEMBER on both doors an author reaches (the bare block + * and a list view's `kanban` block), its order survives the parse, absence + * writes nothing, and the three shapes that can never name a real lane are + * refused at the key: an empty list, a repeated value, and a display label in + * place of the stored value. + */ + +import { describe, it, expect } from 'vitest'; +import { KanbanConfigSchema, ListViewSchema } from './view.zod'; + +const BOARD = { groupByField: 'status', columns: ['subject'] } as const; + +/** The HotCRM task board shape: the view filters out completed tasks. */ +const TASK_BOARD = { + type: 'kanban', + data: { provider: 'object', object: 'crm_task' }, + columns: ['subject', 'priority'], + filter: [{ field: 'is_completed', operator: 'equals', value: false }], +} as const; + +describe('KanbanConfigSchema.lanes', () => { + it('accepts an ordered lane list as a member and keeps its order', () => { + // CONTROL — the parser refuses an unknown key on this very block. + const control = KanbanConfigSchema.safeParse({ ...BOARD, zzUnlikelyBogusKey__: true }); + expect(control.success).toBe(false); + expect(JSON.stringify(control.error?.issues)).toContain('unrecognized_keys'); + + const lanes = ['in_progress', 'not_started', 'waiting']; + const parsed = KanbanConfigSchema.parse({ ...BOARD, lanes }); + expect(parsed.lanes).toEqual(lanes); + }); + + it('is accepted inside a list view, the shape a stored board takes', () => { + const result = ListViewSchema.safeParse({ + ...TASK_BOARD, + kanban: { ...BOARD, lanes: ['not_started', 'in_progress', 'waiting'] }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.data?.kanban?.lanes).toEqual(['not_started', 'in_progress', 'waiting']); + }); + + it('writes nothing when omitted, so a board stored before the key parses unchanged', () => { + const parsed = KanbanConfigSchema.parse(BOARD); + expect('lanes' in parsed).toBe(false); + }); + + it('refuses an empty list at the key, naming the omission that means every option', () => { + const result = KanbanConfigSchema.safeParse({ ...BOARD, lanes: [] }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path[0] === 'lanes'); + expect(issue?.code).toBe('too_small'); + expect(issue?.message).toContain('omit the key'); + }); + + it('refuses a repeated value at its second entry', () => { + const result = KanbanConfigSchema.safeParse({ ...BOARD, lanes: ['waiting', 'in_progress', 'waiting'] }); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe('custom'); + expect(issues[0].path).toEqual(['lanes', 2]); + expect(issues[0].message).toContain('"waiting"'); + }); + + it('refuses a display label in place of the stored option value', () => { + const result = KanbanConfigSchema.safeParse({ ...BOARD, lanes: ['not_started', 'In Progress'] }); + expect(result.success).toBe(false); + const issues = result.error!.issues; + expect(issues).toHaveLength(1); + expect(issues[0].path).toEqual(['lanes', 1]); + expect(issues[0].code).toBe('invalid_format'); + }); +}); diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index 0703cc38c85..bb769171d77 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -52,7 +52,7 @@ import { z } from 'zod'; import { ProtectionSchema } from '../shared/protection.zod'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; import { closedObject, strictObject, strictObjectError, type StrictObjectOptions } from '../shared/strict-object'; -import { SnakeCaseIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; +import { SnakeCaseIdentifierSchema, SystemIdentifierSchema, QUALIFIED_ITEM_NAME_PATTERN } from '../shared/identifiers.zod'; import { EvaluatedExpressionInputSchema } from '../shared/expression.zod'; import { ConditionalFormattingRuleSchema } from '../shared/conditional-formatting.zod'; import { VISIBILITY_STRICT_OPTIONS } from '../shared/visibility-strict-options'; @@ -2046,6 +2046,28 @@ export const EmptyStateSchema = lazySchema(() => strictObject({ ), })); +/** + * `kanban.lanes` names each lane once. A repeated value would draw two lanes + * with one id, and a card with that value belongs to both, so the issue points + * at the second spelling and names the first. + */ +function checkKanbanLanesUnique(lanes: string[], ctx: z.RefinementCtx): void { + const firstAt = new Map(); + lanes.forEach((lane, index) => { + const first = firstAt.get(lane); + if (first === undefined) { + firstAt.set(lane, index); + return; + } + ctx.addIssue({ + code: 'custom', + path: [index], + message: `\`kanban.lanes\` lists ${JSON.stringify(lane)} twice (entries ${first} and ${index}). ` + + 'Each lane is drawn once and a card belongs to exactly one lane, so list each value once.', + }); + }); +} + /** * Kanban Settings */ @@ -2061,6 +2083,49 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({ + 'board reads this name off every card verbatim, so a padded spelling collapses the whole ' + 'board into one `Uncategorized` lane.', ), + /** + * The lanes the board draws, in order: stored option values of the + * `groupByField` field. Without it a board grouped on a select field draws + * one lane per declared option, so a lane the view's own `filter` always + * keeps empty (a task board that filters out completed tasks still drawing + * its `completed` lane) cannot be left off, and the lanes cannot be put in + * any order but the options' own. + * + * Why an ordered list and not a `hideEmptyLanes` flag: a flag hides a lane + * whenever its data happens to be empty, so a lane appears and disappears as + * records move and an empty lane is no longer there to drop a card into; and + * a flag cannot order. The list is stable, says which lanes and in what + * order, and answers the measured case on its own. + * + * A CHOICE of lanes, ⛔ not a record filter. A record whose value is not in + * the list lands in the board's `Uncategorized` lane, exactly as a record + * whose value matches no lane does today, so the board never hides a record + * the view selects. To keep records off the board, filter the view. + * + * Each entry has the grammar of a select option's stored `value` + * (`SelectOptionSchema.value`), so a display label (`'In Progress'`) is + * refused here rather than drawing a lane no record can fall into. Whether + * each entry is an option of `groupByField` is a question about the bound + * object, which this schema cannot see; it is judged where `groupByField` + * itself is judged against the object. + * + * ⛔ No spec-side default: absence keeps every board stored before this key + * exactly as it was, one lane per option. + * + * Lands ahead of its reader: the board draws one lane per option until the + * console half reads this key where it builds its lanes. + */ + lanes: z.array(SystemIdentifierSchema) + .min(1, { message: '`kanban.lanes` lists at least one lane. To draw one lane per option, omit the key.' }) + .superRefine(checkKanbanLanesUnique) + .optional() + .describe( + 'The lanes the board draws, in this order: stored option values of the groupByField field ' + + '(the option `value`, not its label). Omit to draw one lane per option in the options\' own ' + + 'order. A record whose value is not listed is not hidden: it lands in the `Uncategorized` ' + + 'lane, as a record whose value matches no lane does. To keep records off the board, filter ' + + 'the view. Each value may be listed once.', + ), summarizeField: z.string().optional().describe('Field to sum at top of column (e.g. amount)'), /** * [#16894] The one item-titled view config of the family that omitted this From 1fcbb4cb755f6ac2122ea46f335de07f02c3ba2a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:22:31 +0000 Subject: [PATCH 2/5] feat(lint): refuse a kanban lane that is not an option of the group field, or listed twice Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxL3MYcTjfshb8eeK3 --- packages/lint/src/rule-explanations.ts | 33 ++++ .../lint/src/validate-list-view-field-refs.ts | 122 ++++++++++++++ .../validate-list-view-kanban-lanes.test.ts | 149 ++++++++++++++++++ packages/spec/authorable-surface/ui.json | 1 + .../spec/src/ui/kanban-config-lanes.test.ts | 21 +-- packages/spec/src/ui/view.zod.ts | 34 +--- 6 files changed, 318 insertions(+), 42 deletions(-) create mode 100644 packages/lint/src/validate-list-view-kanban-lanes.test.ts diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 40a6364a44b..8093892b1f5 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3530,6 +3530,37 @@ const LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION: RuleExplanation = { ], }; +const LIST_VIEW_KANBAN_LANE_UNKNOWN_EXPLANATION: RuleExplanation = { + rule: 'list-view-kanban-lane-unknown', + covers: 'why every `kanban.lanes` entry must be an option of the group field', + paragraphs: [ + 'A kanban board\'s `lanes` lists the lanes it draws, in order, as stored option values of the field ' + + 'named by `groupByField`. The board puts each card in the lane whose id equals the card\'s value for ' + + 'that field, and a card whose value matches no lane goes to the `Uncategorized` lane.', + 'So a lane spelled as no option has (`inprogress` for `in_progress`, or a label such as `In Progress`) is ' + + 'a lane no card can land in, and every card carrying the value it was meant to name lands in ' + + '`Uncategorized` instead: the board draws the wrong lanes and puts the wrong cards in them. An error, so ' + + 'the publish is refused and `os validate` and `os build` fail.', + 'Judged against the group field\'s own inline `options`. Not judged: a group field whose options come from a ' + + 'shared `picklist` (its options are the list\'s plus its extensions\'), a group field with no inline ' + + '`options` (a `boolean` or a lookup, whose lanes are values rather than options), and a `groupByField` ' + + 'that names no field, which is `list-view-field-unknown`.', + 'To fix it, write the option\'s stored `value`, not its label, or drop the entry. To keep cards off the ' + + 'board, filter the view: `lanes` chooses lanes, it does not filter records.', + ], +}; + +const LIST_VIEW_KANBAN_LANE_REPEATED_EXPLANATION: RuleExplanation = { + rule: 'list-view-kanban-lane-repeated', + covers: 'why a kanban board lists each lane once', + paragraphs: [ + 'Each `kanban.lanes` entry is the id of one lane. A value listed twice draws two lanes with the same id, ' + + 'so a card carrying that value has two lanes to fall into and a drop has two targets with one name.', + 'Reported at the second entry, with or without the bound object: the rule needs no field map. An error, so ' + + 'the publish is refused and `os validate` and `os build` fail. To fix it, delete the repeated entry.', + ], +}; + const LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT_EXPLANATION: RuleExplanation = { rule: 'list-view-chart-dataset-foreign-object', covers: 'why a list view\'s chart must chart the object the list lists', @@ -5373,6 +5404,8 @@ export const RULE_EXPLANATIONS: Readonly> = Obje [LIST_VIEW_FIELD_DOTTED_EXPLANATION.rule]: LIST_VIEW_FIELD_DOTTED_EXPLANATION, [LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION.rule]: LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION, [LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT_EXPLANATION.rule]: LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT_EXPLANATION, + [LIST_VIEW_KANBAN_LANE_UNKNOWN_EXPLANATION.rule]: LIST_VIEW_KANBAN_LANE_UNKNOWN_EXPLANATION, + [LIST_VIEW_KANBAN_LANE_REPEATED_EXPLANATION.rule]: LIST_VIEW_KANBAN_LANE_REPEATED_EXPLANATION, [LIST_VIEW_OBJECT_OUTSIDE_CLOSURE_EXPLANATION.rule]: LIST_VIEW_OBJECT_OUTSIDE_CLOSURE_EXPLANATION, [LIST_VIEW_OBJECT_UNKNOWN_EXPLANATION.rule]: LIST_VIEW_OBJECT_UNKNOWN_EXPLANATION, [TRANSLATION_TARGET_UNKNOWN_EXPLANATION.rule]: TRANSLATION_TARGET_UNKNOWN_EXPLANATION, diff --git a/packages/lint/src/validate-list-view-field-refs.ts b/packages/lint/src/validate-list-view-field-refs.ts index f1ab23d0fa5..34aa7c35d40 100644 --- a/packages/lint/src/validate-list-view-field-refs.ts +++ b/packages/lint/src/validate-list-view-field-refs.ts @@ -367,6 +367,32 @@ * object is the view's own, that object is already inside the written * package's closure. * + * ## [#28018] `kanban.lanes` names option values, once each + * + * `kanban.lanes` chooses the lanes a board draws, in order, as stored option + * values of `kanban.groupByField`. The spec states the grammar of each entry + * and that the list is not empty; it cannot see the object, so the two rules + * that need more are judged here. + * + * - **A lane that is not an option** — {@link LIST_VIEW_KANBAN_LANE_UNKNOWN}, + * `error`. The board buckets each card by its group value, so a lane + * spelled as no option has (`inprogress` for `in_progress`) is a lane no + * card can ever land in, while every card carrying the real value falls + * into the `Uncategorized` lane instead of its own: the board shows the + * wrong lanes and the wrong cards in them. Judged against the group + * field's own inline `options`. + * - **A lane listed twice** — {@link LIST_VIEW_KANBAN_LANE_REPEATED}, + * `error`, at the second entry. Two lanes share one id, so a card with + * that value has two lanes to fall into and a drop has two targets with + * one name. Judged with no field map at all, before any skip below. + * + * Not judged: a group field whose options come from a shared `picklist` (its + * option set is the list's plus every `picklistExtensions` entry, which a + * stack snapshot need not carry whole), a group field with no inline + * `options` (`boolean`, a lookup: their lanes are values, not options), and a + * `groupByField` that resolves to nothing, which is `list-view-field-unknown` + * at that key. + * * ## Skips — the same three every field-existence rule in this package takes * * Resolution is {@link resolveFieldPath}'s and its `unknowable` verdicts are @@ -448,6 +474,18 @@ export const LIST_VIEW_GROUP_ORDER_UNORDERABLE = 'list-view-group-order-unordera */ export const LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT = 'list-view-chart-dataset-foreign-object'; +/** + * [#28018] A `kanban.lanes` entry that is not an option of the group field. + * See the section of that name on this module. + */ +export const LIST_VIEW_KANBAN_LANE_UNKNOWN = 'list-view-kanban-lane-unknown'; + +/** + * [#28018] A `kanban.lanes` value listed more than once. See the section of + * that name on this module. + */ +export const LIST_VIEW_KANBAN_LANE_REPEATED = 'list-view-kanban-lane-repeated'; + /** * A list view that still carries a key `@objectstack/spec` retired — reported * BY NAME, with the tombstone's own prescription, never as a field reference. @@ -1018,6 +1056,87 @@ export function validateListViewFieldRefs( }); }; + /** + * [#28018] The inline option values each object's fields declare, read from + * the same `stack.objects` the graph indexes. A field with no non-empty + * inline `options` array is absent: its lanes are not judged. + */ + const optionValues = new Map>>(); + for (const obj of recordsOf(stack.objects)) { + const objName = strName(obj.name); + if (!objName) continue; + const byField = new Map>(); + for (const field of recordsOf(obj.fields)) { + const fieldName = strName(field.name); + if (!fieldName || !Array.isArray(field.options)) continue; + const values = new Set(); + for (const option of field.options) { + const value = isRec(option) ? strName(option.value) : undefined; + if (value) values.add(value); + } + if (values.size > 0) byField.set(fieldName, values); + } + optionValues.set(objName, byField); + } + + /** + * [#28018] `kanban.lanes`, judged once a list view's bound object resolves: + * every entry must be an inline option of `groupByField`. See the section of + * that name on this module. + */ + const checkKanbanLanes = (listView: AnyRec, object: string, where: string, path: string): void => { + const kanban = listView.kanban; + if (!isRec(kanban) || !Array.isArray(kanban.lanes)) return; + const groupBy = strName(kanban.groupByField); + if (!groupBy) return; + const options = optionValues.get(object)?.get(groupBy); + if (!options) return; + kanban.lanes.forEach((lane, i) => { + const written = strName(lane); + if (!written || options.has(written)) return; + findings.push({ + severity: 'error', + rule: LIST_VIEW_KANBAN_LANE_UNKNOWN, + where: `${where} › kanban`, + path: `${path}.kanban.lanes[${i}]`, + message: + `lanes[${i}] "${written}" is not an option of "${object}.${groupBy}": no card lands in that lane, and ` + + `cards with the option it meant fall into \`Uncategorized\`.` + + suggestName(written, options), + hint: `Write an option's stored \`value\` of "${object}.${groupBy}" (not its label), or drop the entry`, + }); + }); + }; + + /** + * [#28018] A `kanban.lanes` value listed twice, reported at the second entry. + * Needs no field map, so it runs before every skip. + */ + const checkKanbanLaneRepeats = (listView: AnyRec, where: string, path: string): void => { + const kanban = listView.kanban; + if (!isRec(kanban) || !Array.isArray(kanban.lanes)) return; + const firstAt = new Map(); + kanban.lanes.forEach((lane, i) => { + const written = strName(lane); + if (!written) return; + const first = firstAt.get(written); + if (first === undefined) { + firstAt.set(written, i); + return; + } + findings.push({ + severity: 'error', + rule: LIST_VIEW_KANBAN_LANE_REPEATED, + where: `${where} › kanban`, + path: `${path}.kanban.lanes[${i}]`, + message: + `lanes[${i}] "${written}" repeats lanes[${first}]: the board would draw two lanes with one id, so a card ` + + `carrying that value has two lanes to fall into`, + hint: `List each lane once: delete lanes[${i}]`, + }); + }); + }; + /** * Judge ONE written reference at one position. * @@ -1161,6 +1280,7 @@ export function validateListViewFieldRefs( hint: retiredKeyRemedy(prescription), }); } + checkKanbanLaneRepeats(listView, where, path); if (noUniverse) return; // ── Skip 4 (this surface's own): a list view bound to a non-`object` @@ -1250,6 +1370,8 @@ export function validateListViewFieldRefs( } } + checkKanbanLanes(listView, bound, where, path); + // ── Filter KEYS: the view's own filter and the tab presets inside // `userFilters`. `walkFilterFieldKeys` handles all three authored filter // shapes (Mongo condition object, `{ field, operator, value }` rules, diff --git a/packages/lint/src/validate-list-view-kanban-lanes.test.ts b/packages/lint/src/validate-list-view-kanban-lanes.test.ts new file mode 100644 index 00000000000..40031a43e95 --- /dev/null +++ b/packages/lint/src/validate-list-view-kanban-lanes.test.ts @@ -0,0 +1,149 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#28018] `kanban.lanes`, judged against the bound object: every entry is an +// inline option of `groupByField`, and no value is listed twice. The spec +// states each entry's grammar and that the list is not empty; it cannot see the +// object, so these two verdicts live beside the one that judges `groupByField`. + +import { describe, expect, it } from 'vitest'; +import { runAuthoringRules, splitBySeverity } from './authoring-rules.js'; +import { validateReferenceIntegrity } from './reference-integrity-suite.js'; +import { + validateListViewFieldRefs, + LIST_VIEW_FIELD_UNKNOWN, + LIST_VIEW_KANBAN_LANE_UNKNOWN, + LIST_VIEW_KANBAN_LANE_REPEATED, +} from './validate-list-view-field-refs.js'; +import { explainRule } from './rule-explanations.js'; + +type AnyRec = Record; + +const STATUS_OPTIONS = [ + { label: 'Not Started', value: 'not_started' }, + { label: 'In Progress', value: 'in_progress' }, + { label: 'Waiting', value: 'waiting' }, + { label: 'Completed', value: 'completed' }, +]; + +const OBJECTS = [ + { + name: 'crm_task', + label: 'Task', + fields: { + subject: { type: 'text', label: 'Subject' }, + status: { type: 'select', label: 'Status', options: STATUS_OPTIONS }, + industry: { type: 'select', label: 'Industry', picklist: 'industry' }, + is_completed: { type: 'boolean', label: 'Completed' }, + }, + }, +]; + +/** The HotCRM task board: the view keeps completed tasks out, so it leaves the Completed lane off. */ +const TASK_BOARD: AnyRec = { + name: 'task_board', + type: 'kanban', + data: { provider: 'object', object: 'crm_task' }, + columns: ['subject'], + filter: [{ field: 'is_completed', operator: 'equals', value: false }], + kanban: { + groupByField: 'status', + columns: ['subject'], + lanes: ['not_started', 'in_progress', 'waiting'], + }, +}; + +function board(kanban: AnyRec): AnyRec { + return { ...TASK_BOARD, kanban: { ...(TASK_BOARD.kanban as AnyRec), ...kanban } }; +} + +function stackWith(listView: AnyRec, objects: unknown = OBJECTS): AnyRec { + return { name: 'probe', objects, views: [{ name: 'crm_task', object: 'crm_task', list: listView }] }; +} + +const laneFindings = (listView: AnyRec, objects?: unknown) => + validateListViewFieldRefs(stackWith(listView, objects)).filter( + (f) => f.rule === LIST_VIEW_KANBAN_LANE_UNKNOWN || f.rule === LIST_VIEW_KANBAN_LANE_REPEATED, + ); + +describe('#28018 — kanban.lanes judged against the group field', () => { + it('a lane list naming options of the group field is silent, in any order and any subset', () => { + expect(validateListViewFieldRefs(stackWith(TASK_BOARD))).toEqual([]); + expect(laneFindings(board({ lanes: ['completed', 'not_started'] }))).toEqual([]); + }); + + it('a lane that is not an option is an error at its own entry, naming the nearest option', () => { + const findings = laneFindings(board({ lanes: ['not_started', 'inprogress', 'waiting'] })); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [LIST_VIEW_KANBAN_LANE_UNKNOWN, 'error', 'views[0].list.kanban.lanes[1]'], + ]); + expect(findings[0].message).toContain('"crm_task.status"'); + expect(findings[0].message).toContain('Did you mean "in_progress"?'); + }); + + it('an option LABEL in place of its stored value is not an option', () => { + // The spec refuses this spelling by grammar; `os lint` judges the raw + // input without parsing, so the object-aware verdict must name it too. + const findings = laneFindings(board({ lanes: ['In Progress'] })); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [LIST_VIEW_KANBAN_LANE_UNKNOWN, 'views[0].list.kanban.lanes[0]'], + ]); + }); + + it('a value listed twice is an error at the second entry — judged with no field map at all', () => { + const repeated = board({ lanes: ['waiting', 'in_progress', 'waiting'] }); + const expected = [[LIST_VIEW_KANBAN_LANE_REPEATED, 'error', 'views[0].list.kanban.lanes[2]']]; + expect(laneFindings(repeated).map((f) => [f.rule, f.severity, f.path])).toEqual(expected); + // No object universe: every field position switches off, this verdict does not. + expect(laneFindings(repeated, []).map((f) => [f.rule, f.severity, f.path])).toEqual(expected); + }); + + it.each([ + ['a shared picklist', 'industry', ['anything_at_all']], + ['a boolean (no options: its lanes are values)', 'is_completed', ['yes']], + ])('a group field whose options come from %s is not judged', (_label, groupByField, lanes) => { + expect(laneFindings(board({ groupByField, lanes }))).toEqual([]); + }); + + it('a groupByField naming no field is reported once, at groupByField, and its lanes are not judged', () => { + const findings = validateListViewFieldRefs(stackWith(board({ groupByField: 'statuss', lanes: ['nope_lane'] }))); + expect(findings.map((f) => [f.rule, f.path])).toEqual([ + [LIST_VIEW_FIELD_UNKNOWN, 'views[0].list.kanban.groupByField'], + ]); + }); + + for (const command of ['validate', 'build'] as const) { + it(`a lane that is not an option fails \`${command}\``, () => { + const normalized = stackWith(board({ lanes: ['inprogress'] })); + const { errors } = splitBySeverity(runAuthoringRules(command, { normalized })); + expect(errors.map((f) => f.rule)).toContain(LIST_VIEW_KANBAN_LANE_UNKNOWN); + }); + } + + it('reaches the runtime save door through the reference-integrity suite', () => { + const findings = validateReferenceIntegrity( + stackWith(board({ lanes: ['inprogress', 'waiting', 'waiting'] })), + { runtimeWriteType: 'view' }, + ); + const rules = findings.map((f) => f.rule); + expect(rules).toContain(LIST_VIEW_KANBAN_LANE_UNKNOWN); + expect(rules).toContain(LIST_VIEW_KANBAN_LANE_REPEATED); + }); + + it('`os explain` carries the reasoning for both ids, and each verdict and fix is one short line', () => { + for (const rule of [LIST_VIEW_KANBAN_LANE_UNKNOWN, LIST_VIEW_KANBAN_LANE_REPEATED]) { + expect(explainRule(rule), `no \`os explain\` entry for ${rule}`).toBeDefined(); + } + const unknown = explainRule(LIST_VIEW_KANBAN_LANE_UNKNOWN)!.paragraphs.join('\n'); + for (const fact of ['`Uncategorized`', '`picklist`', 'filter the view']) { + expect(unknown, fact).toContain(fact); + } + const fired = validateListViewFieldRefs(stackWith(board({ lanes: ['inprogress', 'waiting', 'waiting'] }))); + expect(fired).toHaveLength(2); + for (const f of fired) { + for (const line of [f.message, f.hint]) { + expect(line).not.toContain('\n'); + expect(line.length, line).toBeLessThanOrEqual(200); + } + } + }); +}); diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8653b7dcf6a..fa44f5aac48 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -693,6 +693,7 @@ "ui/JoinedReportBlock:values", "ui/KanbanConfig:columns", "ui/KanbanConfig:groupByField", + "ui/KanbanConfig:lanes", "ui/KanbanConfig:summarizeField", "ui/KanbanConfig:titleField", "ui/ListChartConfig:chartType", diff --git a/packages/spec/src/ui/kanban-config-lanes.test.ts b/packages/spec/src/ui/kanban-config-lanes.test.ts index acd09297b3f..f0a8a01e34b 100644 --- a/packages/spec/src/ui/kanban-config-lanes.test.ts +++ b/packages/spec/src/ui/kanban-config-lanes.test.ts @@ -6,9 +6,11 @@ * * Legs: the key is a MEMBER on both doors an author reaches (the bare block * and a list view's `kanban` block), its order survives the parse, absence - * writes nothing, and the three shapes that can never name a real lane are - * refused at the key: an empty list, a repeated value, and a display label in - * place of the stored value. + * writes nothing, and the two shapes that can never name a real lane are + * refused at the key: an empty list, and a display label in place of the + * stored value. Membership in `groupByField`'s options and a repeated value + * are judged against the object by `@objectstack/lint` + * (`validate-list-view-field-refs`), which pins them. */ import { describe, it, expect } from 'vitest'; @@ -50,22 +52,11 @@ describe('KanbanConfigSchema.lanes', () => { expect('lanes' in parsed).toBe(false); }); - it('refuses an empty list at the key, naming the omission that means every option', () => { + it('refuses an empty list at the key (omitting the key is what draws every option)', () => { const result = KanbanConfigSchema.safeParse({ ...BOARD, lanes: [] }); expect(result.success).toBe(false); const issue = result.error!.issues.find((i) => i.path[0] === 'lanes'); expect(issue?.code).toBe('too_small'); - expect(issue?.message).toContain('omit the key'); - }); - - it('refuses a repeated value at its second entry', () => { - const result = KanbanConfigSchema.safeParse({ ...BOARD, lanes: ['waiting', 'in_progress', 'waiting'] }); - expect(result.success).toBe(false); - const issues = result.error!.issues; - expect(issues).toHaveLength(1); - expect(issues[0].code).toBe('custom'); - expect(issues[0].path).toEqual(['lanes', 2]); - expect(issues[0].message).toContain('"waiting"'); }); it('refuses a display label in place of the stored option value', () => { diff --git a/packages/spec/src/ui/view.zod.ts b/packages/spec/src/ui/view.zod.ts index bb769171d77..3d7d98abfa5 100644 --- a/packages/spec/src/ui/view.zod.ts +++ b/packages/spec/src/ui/view.zod.ts @@ -2046,28 +2046,6 @@ export const EmptyStateSchema = lazySchema(() => strictObject({ ), })); -/** - * `kanban.lanes` names each lane once. A repeated value would draw two lanes - * with one id, and a card with that value belongs to both, so the issue points - * at the second spelling and names the first. - */ -function checkKanbanLanesUnique(lanes: string[], ctx: z.RefinementCtx): void { - const firstAt = new Map(); - lanes.forEach((lane, index) => { - const first = firstAt.get(lane); - if (first === undefined) { - firstAt.set(lane, index); - return; - } - ctx.addIssue({ - code: 'custom', - path: [index], - message: `\`kanban.lanes\` lists ${JSON.stringify(lane)} twice (entries ${first} and ${index}). ` - + 'Each lane is drawn once and a card belongs to exactly one lane, so list each value once.', - }); - }); -} - /** * Kanban Settings */ @@ -2105,9 +2083,12 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({ * Each entry has the grammar of a select option's stored `value` * (`SelectOptionSchema.value`), so a display label (`'In Progress'`) is * refused here rather than drawing a lane no record can fall into. Whether - * each entry is an option of `groupByField` is a question about the bound - * object, which this schema cannot see; it is judged where `groupByField` - * itself is judged against the object. + * each entry is an option of `groupByField`, and whether one is listed + * twice, is judged where `groupByField` itself is judged against the bound + * object (`validate-list-view-field-refs` in `@objectstack/lint`, which + * `os validate` and the publish gate run): membership needs the object, and + * keeping the duplicate check beside it leaves this key with no refinement + * the published JSON Schema cannot state (`dropped-refinements.baseline.json`). * * ⛔ No spec-side default: absence keeps every board stored before this key * exactly as it was, one lane per option. @@ -2117,14 +2098,13 @@ export const KanbanConfigSchema = lazySchema(() => strictObject({ */ lanes: z.array(SystemIdentifierSchema) .min(1, { message: '`kanban.lanes` lists at least one lane. To draw one lane per option, omit the key.' }) - .superRefine(checkKanbanLanesUnique) .optional() .describe( 'The lanes the board draws, in this order: stored option values of the groupByField field ' + '(the option `value`, not its label). Omit to draw one lane per option in the options\' own ' + 'order. A record whose value is not listed is not hidden: it lands in the `Uncategorized` ' + 'lane, as a record whose value matches no lane does. To keep records off the board, filter ' - + 'the view. Each value may be listed once.', + + 'the view. List each value once.', ), summarizeField: z.string().optional().describe('Field to sum at top of column (e.g. amount)'), /** From 84881450019728422521bcf87c936e20d4896514 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:28:56 +0000 Subject: [PATCH 3/5] chore(spec): regenerate reference docs for kanban.lanes Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxL3MYcTjfshb8eeK3 --- content/docs/references/api/protocol.mdx | 4 ++-- content/docs/references/data/object.mdx | 2 +- content/docs/references/ui/view.mdx | 15 +++++++++------ 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index 4f482878ec1..85c35f8f4f6 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -1863,7 +1863,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1942,7 +1942,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 750b3ad0b84..b8fa4726807 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -382,7 +382,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/content/docs/references/ui/view.mdx b/content/docs/references/ui/view.mdx index 36a2f14f9f9..168b3e1f6fa 100644 --- a/content/docs/references/ui/view.mdx +++ b/content/docs/references/ui/view.mdx @@ -676,6 +676,7 @@ HTTP methods a view data source may request — the subset of `HttpMethod` witho | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. | +| **lanes** | `string[]` | optional | The lanes the board draws, in this order: stored option values of the groupByField field (the option `value`, not its label). Omit to draw one lane per option in the options' own order. A record whose value is not listed is not hidden: it lands in the `Uncategorized` lane, as a record whose value matches no lane does. To keep records off the board, filter the view. List each value once. | | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | @@ -780,7 +781,7 @@ Map view configuration | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -902,6 +903,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. | +| **lanes** | `string[]` | optional | The lanes the board draws, in this order: stored option values of the groupByField field (the option `value`, not its label). Omit to draw one lane per option in the options' own order. A record whose value is not listed is not hidden: it lands in the `Uncategorized` lane, as a record whose value matches no lane does. To keep records off the board, filter the view. List each value once. | | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | @@ -1158,7 +1160,7 @@ View filter rule | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1271,6 +1273,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **groupByField** | `string` | ✅ | Field to group columns by (usually status/select). NO leading or trailing whitespace: the board reads this name off every card verbatim, so a padded spelling collapses the whole board into one `Uncategorized` lane. | +| **lanes** | `string[]` | optional | The lanes the board draws, in this order: stored option values of the groupByField field (the option `value`, not its label). Omit to draw one lane per option in the options' own order. A record whose value is not listed is not hidden: it lands in the `Uncategorized` lane, as a record whose value matches no lane does. To keep records off the board, filter the view. List each value once. | | **summarizeField** | `string` | optional | Field to sum at top of column (e.g. amount) | | **titleField** | `string` | optional | Field displayed as the card title. Omit to fall back to the record display name (ADR-0079 resolver chain) | | **columns** | `string[]` | ✅ | Fields to show on cards | @@ -1730,7 +1733,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -1809,7 +1812,7 @@ Tab configuration for multi-tab view interface | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -2012,7 +2015,7 @@ This schema accepts one of the following structures: | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | @@ -2184,7 +2187,7 @@ This schema accepts one of the following structures: | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | From 1d4af7b4ddc78855bc1878e28bbc868f5745e0de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:31:30 +0000 Subject: [PATCH 4/5] feat(lint): export the kanban lane rule ids and explain them; changeset covers lint Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxL3MYcTjfshb8eeK3 --- .changeset/28018-kanban-lanes.md | 4 +++- packages/lint/src/index.ts | 4 ++++ packages/lint/src/rule-explanations.ts | 2 +- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.changeset/28018-kanban-lanes.md b/.changeset/28018-kanban-lanes.md index f244d4827d0..d83193dc50b 100644 --- a/.changeset/28018-kanban-lanes.md +++ b/.changeset/28018-kanban-lanes.md @@ -1,5 +1,6 @@ --- '@objectstack/spec': minor +'@objectstack/lint': minor --- feat(spec): a kanban board can choose which lanes it draws, and in what order, `kanban.lanes` @@ -12,6 +13,7 @@ A board grouped on a select field drew one lane per option, always, so a lane th - **`lanes: string[]`.** The lanes the board draws, in this order, as stored option values of the `groupByField` field (the option `value`, not its label). For example `lanes: ['not_started', 'in_progress', 'waiting']` on a task board grouped by `status`. - **A choice of lanes, not a record filter.** A record whose value is not listed lands in the board's `Uncategorized` lane, as a record whose value matches no lane already does. To keep records off the board, filter the view. - **Absent means one lane per option.** The key has no spec-side default, so a board stored before it existed parses to exactly the block it stored and draws the lanes it always did. -- **Refused at the key:** an empty list (omit the key instead), a value listed twice, and an entry that is not shaped like a stored option value (a display label such as `'In Progress'`). +- **Refused at parse:** an empty list (omit the key instead), and an entry that is not shaped like a stored option value (a display label such as `'In Progress'`). +- **Refused against the object** (`@objectstack/lint`, so `os validate`, `os build` and the runtime publish gate): a lane that is not an option of the group field (`list-view-kanban-lane-unknown`, which names the nearest option), and a value listed twice (`list-view-kanban-lane-repeated`, at the second entry). A misspelled lane would otherwise draw a lane no card can land in while every card with the option it meant falls into `Uncategorized`. Judged against the group field's inline `options`; a field whose options come from a shared `picklist`, and a group field with no options (a boolean, a lookup), are not judged. `os explain ` carries the reasoning. The Console renderer reads the key in a follow-up change; until then a board that declares it still draws one lane per option. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index acf14b55eac..3bb2971ea43 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -549,12 +549,16 @@ export type { // header query cannot order by (`min` / `max` refused for its type). // [#26719] Its SEVENTH: a list view whose `chart` binds a dataset declared // over another object than the one the list view lists. +// [#28018] Its EIGHTH: a `kanban.lanes` entry that is not an option of the +// group field, or a lane listed twice. export { validateListViewFieldRefs, LIST_VIEW_FIELD_UNKNOWN, LIST_VIEW_FIELD_DOTTED, LIST_VIEW_GROUP_ORDER_UNORDERABLE, LIST_VIEW_CHART_DATASET_FOREIGN_OBJECT, + LIST_VIEW_KANBAN_LANE_UNKNOWN, + LIST_VIEW_KANBAN_LANE_REPEATED, LIST_VIEW_OBJECT_OUTSIDE_CLOSURE, LIST_VIEW_OBJECT_UNKNOWN, LIST_VIEW_KEY_RETIRED, diff --git a/packages/lint/src/rule-explanations.ts b/packages/lint/src/rule-explanations.ts index 8093892b1f5..3be5c32b87a 100644 --- a/packages/lint/src/rule-explanations.ts +++ b/packages/lint/src/rule-explanations.ts @@ -3532,7 +3532,7 @@ const LIST_VIEW_GROUP_ORDER_UNORDERABLE_EXPLANATION: RuleExplanation = { const LIST_VIEW_KANBAN_LANE_UNKNOWN_EXPLANATION: RuleExplanation = { rule: 'list-view-kanban-lane-unknown', - covers: 'why every `kanban.lanes` entry must be an option of the group field', + covers: 'why each kanban lane must name a group field option', paragraphs: [ 'A kanban board\'s `lanes` lists the lanes it draws, in order, as stored option values of the field ' + 'named by `groupByField`. The board puts each card in the lane whose id equals the card\'s value for ' + From c11b0566da2392da8741b4bf4d64beb77e9762e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 15:32:24 +0000 Subject: [PATCH 5/5] docs(changeset): name the explain ids without a placeholder Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibxL3MYcTjfshb8eeK3 --- .changeset/28018-kanban-lanes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/28018-kanban-lanes.md b/.changeset/28018-kanban-lanes.md index d83193dc50b..314bd7ec95a 100644 --- a/.changeset/28018-kanban-lanes.md +++ b/.changeset/28018-kanban-lanes.md @@ -14,6 +14,6 @@ A board grouped on a select field drew one lane per option, always, so a lane th - **A choice of lanes, not a record filter.** A record whose value is not listed lands in the board's `Uncategorized` lane, as a record whose value matches no lane already does. To keep records off the board, filter the view. - **Absent means one lane per option.** The key has no spec-side default, so a board stored before it existed parses to exactly the block it stored and draws the lanes it always did. - **Refused at parse:** an empty list (omit the key instead), and an entry that is not shaped like a stored option value (a display label such as `'In Progress'`). -- **Refused against the object** (`@objectstack/lint`, so `os validate`, `os build` and the runtime publish gate): a lane that is not an option of the group field (`list-view-kanban-lane-unknown`, which names the nearest option), and a value listed twice (`list-view-kanban-lane-repeated`, at the second entry). A misspelled lane would otherwise draw a lane no card can land in while every card with the option it meant falls into `Uncategorized`. Judged against the group field's inline `options`; a field whose options come from a shared `picklist`, and a group field with no options (a boolean, a lookup), are not judged. `os explain ` carries the reasoning. +- **Refused against the object** (`@objectstack/lint`, so `os validate`, `os build` and the runtime publish gate): a lane that is not an option of the group field (`list-view-kanban-lane-unknown`, which names the nearest option), and a value listed twice (`list-view-kanban-lane-repeated`, at the second entry). A misspelled lane would otherwise draw a lane no card can land in while every card with the option it meant falls into `Uncategorized`. Judged against the group field's inline `options`; a field whose options come from a shared `picklist`, and a group field with no options (a boolean, a lookup), are not judged. `os explain list-view-kanban-lane-unknown` (and `-repeated`) carries the reasoning. The Console renderer reads the key in a follow-up change; until then a board that declares it still draws one lane per option.