Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/field-related-list-filter.md
Original file line numberDiff line numberDiff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) Pure accept-set
widening: one new optional key joins the existing field-level related-list
family. Nothing is renamed, retired, narrowed or tombstoned, so there is no
conversion to register and no retirement registry entry. -->

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.
1 change: 1 addition & 0 deletions content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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. |
Expand Down
14 changes: 12 additions & 2 deletions packages/lint/src/filter-walk.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,8 +40,18 @@
/** Any plain metadata record. */
type AnyRec = Record<string, unknown>;

/** Keys whose subtree is a filter. The one place a filter is authored. */
export const FILTER_KEYS: ReadonlySet<string> = 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<string> = new Set(['filter', 'filters', 'runtimeFilter', 'relatedListFilter']);

/** One stack collection a caller wants walked. */
export interface FilterSurface {
Expand Down
37 changes: 37 additions & 0 deletions packages/lint/src/validate-preset-comparands.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
6 changes: 6 additions & 0 deletions packages/spec/liveness/field.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand DownExpand Up@@ -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** |
102 changes: 102 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand Down
34 changes: 34 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
Expand Down
Loading