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
44 changes: 44 additions & 0 deletions .changeset/5874-expandable-field-family-convergence.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
---
'@object-ui/plugin-kanban': patch
'@object-ui/plugin-detail': patch
'@object-ui/app-shell': patch
---

Four more private copies of the reference-bearing field family converge onto
`@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`, and the "fourth and last private copy" claim
that `paramToField` still stated is corrected (objectui#5874, objectui#5875).

Each copy diverged from the published family in BOTH directions, so this is a behaviour
change on every face and not a refactor:

- **`user` and `tree` are now treated as relations.** Both carry the same foreign-key
storage as `lookup` and resolve through the same expand path (objectui#2032), and each
face's own stated reason for special-casing `lookup` applied to them verbatim — so
gaining them restores the rule each face already meant. A `user` / `tree` field is now
read-only in the quick-look drawer (`RecordDetailDrawer`), where the drawer wires no
relation picker and a plain text input let a user overwrite the relation with a
free-form string; it gets the wide layout basis in the record header's highlights strip
(`HeaderHighlight`), whose inline editor is a record picker; and a field-backed action
param over one now inherits the picker config it needs (`resolveActionParams`).
- **`master_detail` is now treated as a relation by `resolveActionParams` too** — it was
the only face missing that member as well, so a field-backed `master_detail` action param
inherited no `referenceTo` at all and degraded to the unexplained "paste a record id"
text input that objectui#3405 exists to prevent.
- **The undeclarable `reference` spelling is gone from the three field-type faces.**
Measured against `@objectstack/spec`'s closed `FieldType` vocabulary with live controls
(`lookup` / `master_detail` / `user` / `tree`) and dead ones (the retired `owner`, plus a
nonsense spelling): `reference` is absent, so no spec-compliant object schema could
declare a field that reached those branches. It sat exactly where `owner` sat before
objectui#4814 retired it — dead weight that read as live capability.

`resolveActionParams` keeps answering for `reference`, deliberately and by a different
route: it is refused by the spec's `ActionParamSchema` too, but the dialog still accepts it
from params already authored with it, and that acceptance belongs to the one alias table in
`paramToField` rather than to a hand-copied membership test. This face now asks the shared
family over the widget key that table produces — the same expression `paramToField`
evaluates one step later, so the half that populates a param's picker config and the half
that forwards it can no longer disagree.

No face copies the set: each calls `.has()` on the object `@object-ui/core` exports, and
each carries an identity pin (a spy on that `has`) so a member-identical private copy fails
instead of quietly re-forking the table.
241 changes: 241 additions & 0 deletions packages/app-shell/src/utils/expandableFamily.identity-5874.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#5874 — `resolveActionParams`' private copy of the reference-bearing
* field family converges onto `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES`.
*
* The copy was `resolvedType === 'lookup' || resolvedType === 'reference'`,
* the test gating `lookupExtras` — the picker config (`referenceTo`,
* `displayField`, `idField`, …) this resolver copies off the underlying object
* field and `paramToField()` then forwards to `<LookupField>`. Neither derived
* from nor pinned against the shared set.
*
* ## Why the load-bearing pin is IDENTITY, not membership
*
* Every membership assertion below is satisfied by a private
* `new Set(['lookup', 'master_detail', 'tree', 'user'])` holding the same
* strings — i.e. by a re-fork of exactly the kind this change removed. So the
* pin that decides the convergence spies on the `has` of the object core
* exports: a call is recorded only if the face under test consulted THAT
* object, so a member-identical private copy leaves the spy empty and fails
* here, where a value check would pass ON the defect. Same shape as
* objectui#4770 / #4790 / #4815 / #5312 / #5692.
*
* ## This face's membership delta is THREE members, not two
*
* Unlike its three sibling faces in objectui#5874 it also lacked
* `master_detail`, so a field-backed `master_detail` param inherited no picker
* config at all and `paramToField()` then degraded it to a plain record-id text
* input — the unexplained "paste a UUID" box objectui#3405 exists to prevent.
*
* `reference` is the one member that does NOT drop on this face, and that is a
* measurement rather than an omission: it is undeclarable (refused by the
* spec's `ActionParamSchema`, pinned below with live and dead controls), but
* the dialog still ACCEPTS it from params already authored with it, via
* `PARAM_TYPE_ALIASES` in `paramToField.ts`, which folds it to `lookup`. The
* convergence therefore asks the shared set over the widget key
* `resolveParamWidgetType()` produces — one alias table, read not copied, and
* the SAME expression `paramToField()` evaluates one step later.
*
* Ablation direction, predicted before running: restore the private copy
* (`resolvedType === 'lookup' || resolvedType === 'reference'`) and the
* identity pin goes RED (the spy records no call) while a member-set assertion
* over `EXPANDABLE_FIELD_TYPES` stays GREEN — that contrast is the whole
* reason the pin is on identity. The restoration probes go red too; the
* ordinary-relation control stays green in both directions, which is what
* makes it a control rather than a duplicate of the pins.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core';
import { FieldType } from '@objectstack/spec/data';
import { ActionParamSchema } from '@objectstack/spec/ui';
import {
resolveActionParams,
type ResolveActionParamsContext,
type RawActionParam,
} from './resolveActionParams';

const SPEC_FIELD_TYPES: readonly string[] = [
...(FieldType as unknown as { options: readonly string[] }).options,
];

/** Every picker key `lookupExtras` copies off the resolved object field. */
const PICKER_KEYS = [
'referenceTo',
'displayField',
'idField',
'descriptionField',
'titleFormat',
'lookupColumns',
'lookupFilters',
'lookupPageSize',
'dependsOn',
] as const;

/** One object field per relevant type, each carrying full picker config. */
const field = (type: string) => ({
type,
label: type,
reference_to: 'accounts',
display_field: 'name',
id_field: 'id',
description_field: 'website',
title_format: '{name}',
lookup_columns: ['name'],
lookup_filters: [['active', '=', true]],
lookup_page_size: 25,
depends_on: ['region'],
});

const ctx = (): ResolveActionParamsContext => ({
objectName: 'deal',
objects: [
{
name: 'deal',
fields: {
account: field('lookup'),
parent_deal: field('master_detail'),
assignee: field('user'),
parent_node: field('tree'),
stage: { type: 'select', label: 'Stage' },
title: { type: 'text', label: 'Title' },
},
},
],
fieldLabel: (_o, _f, fallback) => fallback,
});

const resolveOne = (param: RawActionParam) => resolveActionParams([param], ctx())[0];

afterEach(() => {
// The pins install a spy on the Set object EXPORTED by core — a shared,
// module-level object. A leaked spy would follow every later file in the
// worker, so restoring is not optional here.
vi.restoreAllMocks();
});

describe("resolveActionParams' reference rule is core's object, not a copy (objectui#5874)", () => {
it('asks `@object-ui/core` EXPANDABLE_FIELD_TYPES which params carry picker config', () => {
// The spy is installed on the Set exported by core and records a call only
// if THIS module consulted THAT object. A member-identical private copy
// leaves it empty, so this fails where a value check would pass.
const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has');
try {
resolveOne({ field: 'account' });
expect(spy.mock.calls.map(([k]) => k)).toContain('lookup');
} finally {
spy.mockRestore();
}
});

it('reaches that object on the person path too, not just the lookup path', () => {
// `user` and `lookup` are different members of the same set; a convergence
// that reconnected one spelling only would leave the other forked.
const spy = vi.spyOn(EXPANDABLE_FIELD_TYPES, 'has');
try {
resolveOne({ field: 'assignee' });
expect(spy.mock.calls.map(([k]) => k)).toContain('user');
} finally {
spy.mockRestore();
}
});

it('a member-identical private copy would NOT satisfy the pin — the contrast', () => {
// Documents, executably, why the two pins above are not membership checks:
// this assertion is true of the shared object AND of any private set
// holding the same strings, so it cannot tell a converged face from a
// re-forked one. The spies above can.
const memberIdenticalCopy = new Set(['lookup', 'master_detail', 'tree', 'user']);
expect([...EXPANDABLE_FIELD_TYPES].sort()).toEqual([...memberIdenticalCopy].sort());
expect(EXPANDABLE_FIELD_TYPES).not.toBe(memberIdenticalCopy);
});
});

describe('the restoration half — members this face was missing (objectui#5874)', () => {
// Each must be able to FAIL: before the convergence every one of these params
// reached `paramToField()` with no `referenceTo`, which degrades it to a
// plain record-id text input.
it.each([
['master_detail', 'parent_deal'],
['user', 'assignee'],
['tree', 'parent_node'],
])('a %s param now inherits the full picker config', (_type, fieldName) => {
const out = resolveOne({ field: fieldName });
for (const key of PICKER_KEYS) {
expect(out[key], `${fieldName} lost ${key}`).toBeDefined();
}
expect(out.referenceTo).toBe('accounts');
expect(out.displayField).toBe('name');
});

it('`lookup` — the one member that already worked — is untouched', () => {
const out = resolveOne({ field: 'account' });
expect(out.referenceTo).toBe('accounts');
expect(out.displayField).toBe('name');
});
});

describe('a type outside the family is still NOT reference-bearing — the control', () => {
// Without this, "converge" would be satisfiable by handing picker config to
// every param. It must stay green through BOTH ablation legs.
it.each([
['select', 'stage'],
['text', 'title'],
])('a %s param carries no picker config', (_type, fieldName) => {
const out = resolveOne({ field: fieldName });
for (const key of PICKER_KEYS) {
expect(out[key], `${fieldName} gained ${key}`).toBeUndefined();
}
});
});

describe('`reference` survives HERE, and only through the alias table', () => {
/**
* The measurement, kept as an executable pin rather than as prose in a PR.
* Controls run on the same read as the subject, so a probe that had lost hold
* of the vocabulary (an empty list, the wrong export) fails as a broken probe
* instead of reporting the subject absent.
*/
it('every LIVE control IS a spec `FieldType`, and every DEAD one is not', () => {
for (const type of EXPANDABLE_FIELD_TYPES) {
expect(SPEC_FIELD_TYPES, `'${type}' is not a spec FieldType`).toContain(type);
}
expect(SPEC_FIELD_TYPES).not.toContain('owner');
expect(SPEC_FIELD_TYPES).not.toContain('zzz_not_a_field_type');
});

it('SUBJECT — an author cannot DECLARE a `reference` param', () => {
// Live controls first: if these stopped parsing, the subject reading below
// would mean nothing.
for (const type of EXPANDABLE_FIELD_TYPES) {
expect(
ActionParamSchema.safeParse({ name: 'p', label: 'P', type, reference: 'accounts' }).success,
`'${type}' should be a declarable param type`,
).toBe(true);
}
for (const type of ['reference', 'owner', 'zzz_not_a_field_type']) {
expect(
ActionParamSchema.safeParse({ name: 'p', label: 'P', type, reference: 'accounts' }).success,
`'${type}' should NOT be a declarable param type`,
).toBe(false);
}
});

it('but a param ALREADY authored with it still gets its picker config', () => {
// The legacy dialect `PARAM_TYPE_ALIASES` keeps (`reference` → `lookup`).
// Dropping this face's `reference` branch outright would have silently
// degraded such a param to a text input — which is why the convergence
// asks the shared set over the FOLDED widget key rather than the raw
// spelling. That fold lives in one place; this pins that it is consulted.
const out = resolveOne({ field: 'account', type: 'reference' });
expect(out.type).toBe('reference');
expect(out.referenceTo).toBe('accounts');
expect(out.displayField).toBe('name');
});
});
20 changes: 15 additions & 5 deletions packages/app-shell/src/utils/paramToField.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -163,11 +163,21 @@ describe('paramToField', () => {
/**
* The reference-bearing rule is core's object, not a copy (objectui#5312).
*
* This module held the FOURTH and last hand-maintained answer to one question —
* "does this widget resolve a foreign key, so hand it the reference target?" —
* as the inline disjunction `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`.
* The other three converged on `@object-ui/core`'s `EXPANDABLE_FIELD_TYPES` in
* objectui#4770 / #4790 / #4815.
* This module was the FOURTH CONVERSION of one hand-maintained answer to one
* question — "does this widget resolve a foreign key, so hand it the reference
* target?" — held here as the inline disjunction
* `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`. The three conversions
* before it were objectui#4770 / #4790 / #4815.
*
* This docblock used to call this face the LAST copy as well as the fourth.
* Fourth converted, yes; last copy, no — objectui#5692 then found two older
* copies in `plugin-dashboard`, and objectui#5874 four more. No replacement
* count is written here on purpose: a hand-kept census is the thing that keeps
* going stale, and a bigger integer would only re-create the defect. The
* census and its falsification live once, in the family's canonical home — see
* the "One family, many consumers — and NO reliable count of them" section of
* `packages/core/src/utils/expand-fields.ts`. What is mechanical, rather than
* hand-kept, is the identity pin below (objectui#5875).
*
* Every membership assertion in this file is satisfied by a private
* `new Set(['lookup', 'master_detail', 'user', 'tree'])` holding the same
Expand Down
16 changes: 14 additions & 2 deletions packages/app-shell/src/utils/paramToField.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -136,10 +136,22 @@ export function paramToField(param: ActionParamDef): Record<string, any> {
// `EXPANDABLE_FIELD_TYPES` from `@object-ui/core`, the one relational-field
// family that `buildExpandFields`, the predicate-record projection, the object
// form's `needsDataSourceWiring` and the grid's `bulkParamToField` already
// read (objectui#4770 / #4790 / #4815). This face held the fourth and last
// private copy of it — `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`,
// read (objectui#4770 / #4790 / #4815). This face was the fourth CONVERSION
// of it — the private copy `LOOKUP_WIDGET_TYPES.has(type) || type === 'user'`,
// once with a fifth spelling `owner` that objectui#4814 retired (ruling A′).
//
// This comment used to add "and last". It was true as far as it had been
// measured and is now known false, twice over: objectui#5692 found two older
// copies in `plugin-dashboard`, and objectui#5874 four more (kanban, detail
// ×2, `resolveActionParams`). No count is restated here on purpose — a
// hand-kept census of this table is exactly what keeps going stale, and
// writing a bigger integer would only re-create the defect. The census, its
// falsification and the lineage of the conversions live in ONE place, the
// family's canonical home: see the "One family, many consumers — and NO
// reliable count of them" and "The LAST-private-copy claim was false"
// sections of `packages/core/src/utils/expand-fields.ts`. The mechanical
// fact, here and on every converted face, is the identity pin (objectui#5875).
//
// The comment that stood here claimed the disjunction "moves in lockstep with
// plugin-grid's `bulkParamToField` twin — the two param faces are never
// split". Measured on the tip before this change, that was false in BOTH
Expand Down
40 changes: 39 additions & 1 deletion packages/app-shell/src/utils/resolveActionParams.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -29,7 +29,11 @@
* rejects it, the server's `.strict()` parse rejects it, and this resolver
* names it via {@link RESOLVED_ONLY_PARAM_KEYS} rather than reading it.
*/
import { EXPANDABLE_FIELD_TYPES } from '@object-ui/core';
import type { ActionParamDef, ActionParamOption } from '@object-ui/core';
// The fold from a PARAM type spelling to the widget key that renders it —
// the one alias table, read rather than restated. See `isLookupResolvedType`.
import { resolveParamWidgetType } from './paramToField.js';
import type { I18nLabel } from '@objectstack/spec/ui';
// Aliased per PR #4169's convention — app-shell has its OWN `resolveI18nLabel`
// (renamed `resolveKeyedI18nLabel` by objectui#4167) over the translation-KEY
Expand DownExpand Up@@ -486,7 +490,41 @@ export function resolveActionParam(
/** Lookup/reference params carry extra picker config that the dialog
* forwards to `<LookupField>`. Without these the picker would fall back
* to a plain text input. */
const isLookupResolvedType = resolvedType === 'lookup' || resolvedType === 'reference';
// Which params are reference-bearing is NOT restated here. It is
// `EXPANDABLE_FIELD_TYPES` in `@object-ui/core` — the one relational family
// (objectui#4770 / #4790 / #4815 / #5312 / #5692) — asked over the widget key
// `resolveParamWidgetType()` folds this spelling onto. That is deliberately
// the SAME expression `paramToField()` evaluates one step later: this half
// POPULATES the picker group below and that half FORWARDS it to the widget,
// so a divergence between them silently drops a picker's config. Asking the
// one question through the one alias table is what makes them agree
// mechanically rather than by hand (objectui#5874).
//
// The literal that stood here diverged from the family in BOTH directions,
// and on THREE members rather than the two its three sibling faces missed:
//
// - it lacked `master_detail`, `user` and `tree`. A field-backed param over
// any of them inherited NO picker config, so `paramToField()` then found
// no `referenceTo` and degraded the param to a plain record-id text input
// — the unexplained "paste a UUID" box objectui#3405 exists to prevent.
// Gaining them RESTORES the rule this block states; it does not widen it.
// - it carried `reference`, and that spelling survives the change: the fold
// is where it belongs (`PARAM_TYPE_ALIASES` in `paramToField.ts` keeps it
// as a legacy param dialect, folded to `lookup`), not hand-copied into a
// membership test. Measured, not assumed — `reference` is refused by
// `ActionParamSchema` and absent from `@objectstack/spec`'s `FieldType`,
// with `lookup` / `master_detail` / `user` / `tree` accepted as live
// controls and retired `owner` plus a nonsense spelling refused as dead
// ones — so it is undeclarable, but the dialog still ACCEPTS it from
// params already authored with it, and that acceptance is the alias
// table's to state, once.
//
// Pinned by an identity spy on that `has`, so a member-identical private copy
// fails rather than quietly re-forking. Never
// `new Set([...EXPANDABLE_FIELD_TYPES, ...])`.
const isLookupResolvedType = EXPANDABLE_FIELD_TYPES.has(
resolveParamWidgetType(resolvedType),
);
const lookupExtras: Partial<ActionParamDef> = isLookupResolvedType
? {
// Inline `reference` wins, matching how every other inline value
Expand Down
Loading
Loading