diff --git a/.changeset/field-related-list-filter.md b/.changeset/field-related-list-filter.md new file mode 100644 index 0000000000..50c2f3c00a --- /dev/null +++ b/.changeset/field-related-list-filter.md @@ -0,0 +1,38 @@ +--- +"@objectstack/spec": minor +"@objectstack/lint": minor +--- + +feat(spec): field-level `relatedListFilter` — a declarative default filter for auto-derived related lists (#8704) + + + +The field-level related-list family (`relatedList` / `relatedListTitle` / +`relatedListColumns`) gains its fourth member, `relatedListFilter` — closing the +gap where the only way to filter an auto-derived related list was to abandon the +auto-derived record page for a hand-written `record:related_list` page +(maintainer ruling 2026-08-15 on #8704). + +- **No new filter dialect**: the key carries the canonical Query-DSL + `FilterCondition` (the same authoring face as a query `where`, dataset scope + filters, and `summaryOperations.filter`). The FILTER-axis doors therefore + apply automatically — the schema door refuses bare date-range preset + comparands in ordering positions at parse (#8793), and the engine doors judge + the composed query at run time (`formula` keys refused `INVALID_FIELD`, + #8296). +- **Contract semantics, pinned**: the declared constraint is AND-composed with + the parent-relationship condition `{ [referenceField]: parentId }` — an + authored constraint, never a user-editable suggestion — and the related-list + tab badge count honors the same composed filter, so counts match visible + rows. Both clauses are normative in the key's contract text and pinned by + tests. +- **`@objectstack/lint`**: the shared authored-filter walk (`FILTER_KEYS`) now + recognizes `relatedListFilter`, extending the filter-token, empty-combinator + and preset-comparand rules to the new position. + +The consumption half (RecordDetailView auto-derivation + tab badge) is +objectui#4664, `Blocked-by:` this change; until it lands the key is ledgered +`planned` with an author warning. diff --git a/content/docs/references/data/field.mdx b/content/docs/references/data/field.mdx index 184af3a147..a86fcbb360 100644 --- a/content/docs/references/data/field.mdx +++ b/content/docs/references/data/field.mdx @@ -84,6 +84,7 @@ const result = CurrencyConfigSchema.parse(data); | **relatedList** | `boolean \| 'primary'` | optional | Show this child collection as a related list on the parent's detail page (read-side mirror of inlineEdit). false = suppress; true/absent = shown (stacked under the shared "Related" tab); 'primary' = core relationship, promoted to its own tab. Prominence intent, not a layout switch (ADR-0085). | | **relatedListTitle** | `string` | optional | Title for the detail-page related list | | **relatedListColumns** | `any[]` | optional | Explicit columns for the detail-page related list (derived from the child object when omitted) | +| **relatedListFilter** | `any` | optional | Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition `{ [referenceField]: parentId }` — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. `{ status: { $ne: 'deleted' } }` to hide soft-deleted children. | | **displayField** | `string` | optional | Field shown as each candidate's label in the picker/popover (defaults to the referenced object's name/title). | | **descriptionField** | `string` | optional | Secondary field shown under the label in the quick-select popover. | | **lookupColumns** | `(string \| { field: string; label?: string; width?: string; type?: string })[]` | optional | Explicit columns for the record-picker table; auto-derived from the referenced object when omitted. | diff --git a/packages/lint/src/filter-walk.ts b/packages/lint/src/filter-walk.ts index 52d26cded4..a17e7aa188 100644 --- a/packages/lint/src/filter-walk.ts +++ b/packages/lint/src/filter-walk.ts @@ -40,8 +40,18 @@ /** Any plain metadata record. */ type AnyRec = Record; -/** Keys whose subtree is a filter. The one place a filter is authored. */ -export const FILTER_KEYS: ReadonlySet = new Set(['filter', 'filters', 'runtimeFilter']); +/** + * Keys whose subtree is a filter. The one place a filter is authored. + * + * `relatedListFilter` (#8704) is the one member that does not spell the key + * `filter`: it sits flat on a FIELD beside its `relatedList`/`relatedListTitle`/ + * `relatedListColumns` family, so the family naming wins over the filter-key + * convention. It carries a canonical Query-DSL `FilterCondition` (the schema + * door already judges it at parse), and listing it here is what extends the + * three walking rules — tokens, empty combinators, preset comparands — to the + * new position instead of leaving a per-rule hole. + */ +export const FILTER_KEYS: ReadonlySet = new Set(['filter', 'filters', 'runtimeFilter', 'relatedListFilter']); /** One stack collection a caller wants walked. */ export interface FilterSurface { diff --git a/packages/lint/src/validate-preset-comparands.test.ts b/packages/lint/src/validate-preset-comparands.test.ts index c8729e1dca..9e045fb0e4 100644 --- a/packages/lint/src/validate-preset-comparands.test.ts +++ b/packages/lint/src/validate-preset-comparands.test.ts @@ -143,6 +143,43 @@ describe('validatePresetComparands (#8793 — the ruled C half of #8690)', () => ].sort()); }); + it('[#8704] reaches a field-level relatedListFilter under objects', () => { + // `relatedListFilter` is the one FILTER_KEYS member not spelled `filter`: + // it rides flat on the field beside its relatedList* family. Listing it in + // the shared walk is what lands this rule (and tokens/empty-combinators) + // on the new position — this pin holds that coverage. + const findings = validatePresetComparands({ + objects: [{ + name: 'account', + fields: { + task: { + type: 'lookup', + reference: 'account', + relatedListFilter: { created_at: { $gte: 'last_30_days' } }, + }, + }, + }], + }); + expect(findings).toHaveLength(1); + expect(findings[0].path).toBe('objects[0].fields.task.relatedListFilter.created_at.$gte'); + expect(findings[0].message).toContain('last_30_days'); + }); + + it('[#8704] POSITIVE CONTROL: a clean relatedListFilter reports nothing', () => { + expect(validatePresetComparands({ + objects: [{ + name: 'account', + fields: { + task: { + type: 'lookup', + reference: 'account', + relatedListFilter: { status: { $ne: 'deleted' }, created_at: { $gte: '{30_days_ago}' } }, + }, + }, + }], + })).toEqual([]); + }); + it('does not double-report a value the token rule already owns', () => { // `{last_30_days}` is a WRAPPED unknown token — validate-filter-tokens' // verdict (FILTER_TOKEN_UNKNOWN), not this rule's: a preset name carries diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index db07bb56c1..3ea105b6da 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -380,6 +380,7 @@ "data/Field:reference", "data/Field:relatedList", "data/Field:relatedListColumns", + "data/Field:relatedListFilter", "data/Field:relatedListTitle", "data/Field:required", "data/Field:requiredPermissions", diff --git a/packages/spec/liveness/field.json b/packages/spec/liveness/field.json index 012faa91ec..89fccb97fe 100644 --- a/packages/spec/liveness/field.json +++ b/packages/spec/liveness/field.json @@ -279,6 +279,12 @@ "evidence": "objectui: packages/app-shell/src/utils/deriveRelatedLists.ts + views/RecordDetailView.tsx", "note": "LIVE via objectui renderer — the 2026-06 audit mis-classified as dead (renderer side not re-verified). Corrected after checking ../objectui." }, + "relatedListFilter": { + "status": "planned", + "authorWarn": true, + "authorHint": "Declared (#8704) ahead of its consumer: the auto-derived related list does not apply this filter yet — objectui#4664 (RecordDetailView derivation + tab badge) is the consumption half. Until it lands, child rows this filter is meant to hide still render.", + "note": "[#8704] Contract-first spec half (maintainer ruling 2026-08-15, 「接受全部建议。」 item 3): the declarative default filter the auto-derived related list AND-composes with { [referenceField]: parentId }, badge-count parity included — both clauses normative in the key's describe()/JSDoc and pinned in field.test.ts. Reuses FilterConditionSchema, so the #8793 bare-preset schema door judges it at parse (pinned at this position) and the engine FILTER doors (#8296 formula refusal etc.) judge the composed query at run time. `planned` + authorWarn per enforce-or-mark, the app.navigation runAction (#4848) shape rather than the useGrouping one: an author who sets this today IS misled — the soft-deleted child rows the filter exists to hide keep rendering until the consumer lands. Flip to `live` (and drop authorWarn) with a deriveRelatedLists/RecordDetailView evidence pointer when objectui#4664 lands." + }, "trackHistory": { "status": "live", "evidence": "packages/plugins/plugin-audit/src/audit-writers.ts", diff --git a/packages/spec/liveness/state-counts.md b/packages/spec/liveness/state-counts.md index 9861fb40a0..388ad8eb1e 100644 --- a/packages/spec/liveness/state-counts.md +++ b/packages/spec/liveness/state-counts.md @@ -28,7 +28,7 @@ for both corollaries. | Type | live | exp | dead | planned | classified | |---|---|---|---|---|---| | `object` | 50 | 0 | 0 | 1 | 51 | -| `field` | 67 | 0 | 0 | 1 | 68 | +| `field` | 67 | 0 | 0 | 2 | 69 | | `flow` | 34 | 0 | 6 | 0 | 40 | | `action` | 42 | 0 | 2 | 0 | 44 | | `hook` | 18 | 0 | 2 | 0 | 20 | @@ -57,4 +57,4 @@ for both corollaries. | `api` | 25 | 0 | 0 | 2 | 27 | | `capability` | 12 | 0 | 0 | 0 | 12 | | `qa` | 4 | 0 | 5 | 0 | 9 | -| **total** | **775** | **6** | **55** | **8** | **844** | +| **total** | **775** | **6** | **55** | **9** | **845** | diff --git a/packages/spec/src/data/field.test.ts b/packages/spec/src/data/field.test.ts index 53ee41ded3..5dae0ea21c 100644 --- a/packages/spec/src/data/field.test.ts +++ b/packages/spec/src/data/field.test.ts @@ -452,6 +452,108 @@ describe('FieldSchema', () => { expect(() => FieldSchema.parse(field)).toThrow(); }); + // [#8704] relatedListFilter — the fourth member of the related-list family. + it('should accept relatedListFilter (canonical Query-DSL FilterCondition) and round-trip it', () => { + const field: Field = { + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + relatedList: 'primary', + relatedListTitle: 'Open Tasks', + // The motivating soft-delete case, plus an explicit-operator sibling. + relatedListFilter: { status: { $ne: 'deleted' }, archived: false }, + }; + const result = FieldSchema.parse(field); + expect(result.relatedListFilter).toEqual({ status: { $ne: 'deleted' }, archived: false }); + }); + + it('relatedListFilter supports the full FilterCondition shape ($and/$or/$not, nested)', () => { + const result = FieldSchema.parse({ + name: 'project', + label: 'Project', + type: 'master_detail', + reference: 'crm_project', + relatedListFilter: { + $and: [{ status: { $nin: ['deleted', 'archived'] } }, { $not: { hidden: true } }], + }, + }); + expect(result.relatedListFilter).toEqual({ + $and: [{ status: { $nin: ['deleted', 'archived'] } }, { $not: { hidden: true } }], + }); + }); + + // [#8704 ruling condition 1] Reusing FilterConditionSchema — not a new + // dialect — is what makes the FILTER-axis schema door (#8793 bare preset + // comparands) cover this position with NO new wiring. This pin measures + // that the door really reaches the new key. + it('relatedListFilter is judged by the #8793 bare-preset-comparand schema door', () => { + const r = FieldSchema.safeParse({ + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + relatedListFilter: { created_at: { $gte: 'last_30_days' } }, + }); + expect(r.success).toBe(false); + if (!r.success) { + const issue = r.error.issues.find((i) => i.message.includes('last_30_days')); + expect(issue, 'the preset refusal must surface through relatedListFilter').toBeTruthy(); + expect(issue!.path).toEqual(['relatedListFilter', 'created_at', '$gte']); + expect(issue!.message).toContain('#8793'); + } + }); + + // [#8371 / PR #8936] Dotted filter heads are deliberately NOT judged at + // this field-agnostic schema door: the field-typed doors own them at query + // time (ingress `assertFilterFieldsExist` in @objectstack/metadata-protocol, + // engine `assertFilterIsMaterializable` in @objectstack/objectql, sharing + // spec's `classifyDottedFilterHead`), and the related-list query composed + // from this key reaches them like any other `where`. This pin holds the + // door PLACEMENT — parse-clean here, so a schema-side refusal (which + // cannot see the CHILD object's field types) does not creep in. + it('relatedListFilter leaves dotted heads to the field-typed engine doors (#8371)', () => { + const r = FieldSchema.safeParse({ + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + relatedListFilter: { 'project_id.name': 'Acme' }, + }); + expect(r.success).toBe(true); + }); + + it('POSITIVE CONTROL: relatedListFilter with the macro/ISO spellings publishes cleanly', () => { + for (const relatedListFilter of [ + { created_at: { $gte: '{30_days_ago}' } }, + { created_at: { $gte: '2026-01-15' } }, + { status: { $ne: 'deleted' } }, + ]) { + const r = FieldSchema.safeParse({ + name: 'account', + label: 'Account', + type: 'lookup', + reference: 'crm_account', + relatedListFilter, + }); + expect(r.success, JSON.stringify(relatedListFilter)).toBe(true); + } + }); + + // [#8704 ruling condition 2] The AND-composition and badge-count-parity + // clauses are the key's CONTRACT. The auto-derivation that consumes the + // key lives wholly in objectui (deriveRelatedLists / RecordDetailView — + // companion card objectui#4664), so the artifact this spec can enforce is + // the normative contract text every consumer and doc reader is handed. + // This pin keeps both clauses in the published `.describe()`. + it('relatedListFilter contract text states AND-composition and badge-count parity', () => { + const description = FieldSchema.shape.relatedListFilter.description ?? ''; + expect(description).toContain('AND-composed'); + expect(description).toContain('{ [referenceField]: parentId }'); + expect(description).toContain('badge count honors the same composed filter'); + expect(description).toContain('never a user-editable suggestion'); + }); + it('should preserve forward record-picker config (display/columns/filters/depends)', () => { const lookupField: Field = { name: 'account', diff --git a/packages/spec/src/data/field.zod.ts b/packages/spec/src/data/field.zod.ts index 89d965c4dc..c7a2876ca9 100644 --- a/packages/spec/src/data/field.zod.ts +++ b/packages/spec/src/data/field.zod.ts @@ -800,6 +800,40 @@ export const FieldSchema = lazySchema(() => strictObject({ relatedListTitle: z.string().optional().describe('Title for the detail-page related list'), /** Optional explicit columns for the detail-page related list (derived from the child object when omitted). */ relatedListColumns: z.array(z.any()).optional().describe('Explicit columns for the detail-page related list (derived from the child object when omitted)'), + /** + * Declarative default FILTER for the detail-page related list (#8704). The + * auto-derived related list for this relationship queries the child object + * with this constraint AND-composed with the parent-relationship condition + * `{ [referenceField]: parentId }` — the effective predicate is the + * conjunction, so a child row appears only when it points at the parent AND + * matches this filter. Two clauses are CONTRACT, binding on every consumer + * of the derived related-list descriptor (maintainer ruling 2026-08-15 on + * #8704, 「接受全部建议。」 item 3): + * + * - AUTHORED CONSTRAINT, never a user-editable suggestion — a viewer + * cannot remove or relax it from the rendered list. + * - BADGE-COUNT PARITY — the related-list tab badge count is computed + * over the SAME composed predicate, so the count always matches the + * visible rows. A count the filter does not reach ships a silent lie + * (rows hidden, count unchanged); parity is part of this key's + * semantics, not a consumer nicety. + * + * The canonical use is excluding soft-deleted child rows + * (`{ status: { $ne: 'deleted' } }`) without abandoning the auto-derived + * record page for a hand-written `record:related_list` page. + * + * REUSES the canonical Query-DSL {@link FilterConditionSchema} — the same + * authoring face as a query `where`, dataset scope filters, and this file's + * own `summaryOperations.filter` (which ANDs with the parent-FK match in + * exactly the same way) — deliberately NOT a new dialect, so the FILTER-axis + * doors apply here automatically: the schema door refuses bare date-range + * preset comparands in ordering positions at parse (#8793), and the engine + * doors judge the composed query at run time like any other `where` (a + * virtual `formula` key is refused with `INVALID_FIELD`, #8296). Like its + * three siblings above, it is meaningful on a child's + * `master_detail`/`lookup` field (whose `reference` is the parent). + */ + relatedListFilter: FilterConditionSchema.optional().describe("Declarative default filter for the detail-page related list: AND-composed with the parent-relationship condition { [referenceField]: parentId } — an authored constraint, never a user-editable suggestion. The related-list tab badge count honors the same composed filter, so counts match the visible rows. Canonical Query-DSL FilterCondition (the same dialect as a query `where`), e.g. { status: { $ne: 'deleted' } } to hide soft-deleted children."), /** * LOOKUP PICKER (forward) config — how THIS lookup/master_detail field's