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
15 changes: 15 additions & 0 deletions .changeset/25937-user-cell-sys-user.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/fields': patch
---

fix(fields): a `user` field's person object is always `sys_user` — the user cell and the person picker no longer read an authored `reference`

Clause-②: no
Metadata-protocol: none

A `user` field's target is a constant of its type: `@objectstack/spec` refuses a `user` field whose `reference` names any object but `sys_user`, and `referenceTargetOf` resolves every `user` field to `sys_user` whatever the field carries. The console now agrees with it in two places:

- **`UserCellRenderer`** (read-only cell): the field-level read gate on the person's name and avatar is keyed by `sys_user`. A stored object saved before the spec refused a foreign `reference` (for example `reference: 'crm_member'`) used to gate the cell by that object's policy; it is now gated by the `sys_user` policy, the object the runtime expands the field into.
- **`UserField`** (person picker): the picker targets `sys_user` even when the field carries a foreign `reference`, so it offers users, matching what the runtime stores and resolves.

What to do: nothing for a spec-valid `user` field (no `reference`, or `reference: 'sys_user'`). A field that links to another object is a `lookup`.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/fields.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/fields","sources":96,"sha256":"a80484e00e8bc69907a6efbec0af09bd7eb0d1a78b057a4f5092b90432d921cc","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/fields","sources":96,"sha256":"14f77ca68180dba0ba0226c934cc41bbb0c4d317a1472fb3512d75cf3b4ab7ac","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-grid.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"871e72570111ff4df50cd03ad7e7f98045364b7d8c7b11ceead655308a9f263a","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-grid","sources":38,"sha256":"d34e7acc9010e7fc875131b416c4a83a2bac115c893c2067d3ccb85fcaf8bcd0","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
28 changes: 17 additions & 11 deletions ui/packages/fields/src/__tests__/userCell.readGate-10535.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@
*
* - single and multi: a denied name and a denied image are not drawn; the
* cell reads exactly as a stripping backend's row makes it read;
* - the person's object is the field's `reference`, and `sys_user` when it
* names none — the answer `UserField` gives the picker; a retired
* `reference_to` names nothing (objectui#11070 round 4);
* - the person's object is always `sys_user`, a constant of the `user` type
* (objectstack#24952) — the answer `UserField` gives the picker and the
* target the runtime's `referenceTargetOf` resolves: an authored foreign
* `reference` (a stored body saved before the spec refused it) is not read,
* and neither is a retired `reference_to` (objectui#11070 round 4);
* - a policy that changes after mount relabels the SAME mounted cell;
* - lit controls: fields the policy does NOT deny still print, and with no
* policy loaded (no provider: `isLoaded` is false) the row is drawn as
Expand Down Expand Up @@ -151,17 +153,21 @@ describe('UserCellRenderer — the name and the avatar are drawn from the fields
expect(gated.html).toBe(served.html);
});

it("the person's object is the field's `reference`, and `sys_user` when it names none", () => {
// `reference` is the spec's spelling and, since objectui#11070 round 4, the
// only one read. `UserFieldMetadata` does not declare it, hence the cast.
it("a foreign `reference` is not read — the person's object is `sys_user`, the `user` type's constant (objectstack#24952)", () => {
// The spec refuses this field, and `referenceTargetOf` resolves it to
// `sys_user` anyway, so a stored body saved before the refusal still
// reaches the cell. `UserFieldMetadata` does not declare `reference`,
// hence the cast.
const field = { type: 'user', reference: 'crm_member' } as unknown as FieldMetadata;
const gated = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') });
// The cell gates by the `sys_user` policy, the object the runtime resolves…
const gated = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') });
for (const secret of SECRETS) expect(gated.html).not.toContain(secret);
expect(gated.html).toBe(drawn({ value: AMY, policy: policyDenying('sys_user', 'name', 'image') }).html);

// A policy on `sys_user` says nothing about a field that points elsewhere.
const elsewhere = drawn({ value: AMY, field, policy: policyDenying('sys_user', 'name', 'image') });
expect(elsewhere.text).toContain('Amy Lin');
expect(elsewhere.imgs).toEqual(['http://x/amy.png']);
// …and a policy on the authored object does not reach it.
const unreached = drawn({ value: AMY, field, policy: policyDenying('crm_member', 'name', 'image') });
expect(unreached.text).toContain('Amy Lin');
expect(unreached.imgs).toEqual(['http://x/amy.png']);
});

it('a retired `reference_to` names no person object — the cell reads `sys_user` (objectui#11070 round 4)', () => {
Expand Down
16 changes: 9 additions & 7 deletions ui/packages/fields/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3142,17 +3142,19 @@ export function UnresolvedReferenceMark({
* the viewer may read it (objectui#10535): with a loaded policy, the fields it
* denies on the person's object are removed first ({@link withoutDeniedFields},
* `id` kept), and everything below reads that row — exactly as it reads the
* row a stripping backend serves. The person's object is the field's
* `reference`, and `sys_user` when it names none: the object `UserField`
* points the picker at.
* row a stripping backend serves. The person's object is always `sys_user`,
* a constant of the `user` type (objectstack#24952): the spec refuses a `user`
* field whose `reference` names any other object, and `referenceTargetOf`
* resolves every `user` field to `sys_user` whatever its carrier says — so a
* stored body that still carries a foreign `reference` is gated by the
* `sys_user` policy, the object the runtime expands it into and the object
* `UserField` points the picker at. The field's `reference` is not read.
*/
export function UserCellRenderer({ value, field }: CellRendererProps): React.ReactElement {
export function UserCellRenderer({ value }: CellRendererProps): React.ReactElement {
// Called before any early return (rules of hooks). A policy that loads or
// changes re-renders this cell through the context, and the drawing follows.
const perms = usePermissions();
const personObject =
(field as { reference?: string } | undefined)?.reference ||
'sys_user';
const personObject = 'sys_user';

// THE FLOOR by name (objectui#8496) plus ONE extension: every falsy scalar.
// `!value` alone never saw `[]` — a truthy empty array reached the
Expand Down
13 changes: 8 additions & 5 deletions ui/packages/fields/src/widgets/UserField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,16 @@ export function UserField(props: FieldWidgetComponentProps<any>) {
&& ('reference' in raw.field || 'type' in raw.field);
const meta = metaIsNested ? raw.field : raw;

// Ensure the picker always targets sys_user (even if the author omitted an
// explicit reference), presents user names by default, and defaults to the
// search-first PeoplePicker with sensible person display + candidate hygiene.
// `reference` is the only target spelling read (objectui#11070 round 4).
// The picker always targets sys_user, presents user names by default, and
// defaults to the search-first PeoplePicker with sensible person display +
// candidate hygiene. The target is a constant of the `user` type
// (objectstack#24952): the spec refuses a foreign `reference` on a `user`
// field and `referenceTargetOf` resolves every `user` field to `sys_user`, so
// an authored `reference` is not read — a stored body that still carries a
// foreign one picks users, as the runtime resolves it.
const normalized = {
...(meta || {}),
reference: meta?.reference || 'sys_user',
reference: 'sys_user',
displayField: meta?.displayField || meta?.reference_field || 'name',
picker: meta?.picker ?? 'search',
subtitle: meta?.subtitle ?? ['primary_business_unit_id.name', 'email'],
Expand Down
2 changes: 1 addition & 1 deletion ui/packages/plugin-grid/src/relationalMetaKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export interface RelationalMetaEntry {
*/
export const RELATIONAL_META_READ_SET: Readonly<Record<string, RelationalMetaEntry>> = {
// ── The relational target ────────────────────────────────────────────────
reference: { verdict: 'spec', readers: ALL_THREE, note: "FieldSchema.reference — the served spelling for a lookup's target object, and since objectui#11070 round 4 the only one any consumer reads." },
reference: { verdict: 'spec', readers: CELL_AND_LOOKUP_EDITOR, note: "FieldSchema.reference — the served spelling for a lookup's target object, and since objectui#11070 round 4 the only one any consumer reads." },
// ⭐ objectui#11070 round 4 removed the `reference_to` row. It is not a
// verdict change: the key left this table because the three consumers
// stopped reading it and the choke point stopped stamping it, and a row here
Expand Down
Loading