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
53 changes: 53 additions & 0 deletions .changeset/7613-recipient-picker-field-kind.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
---
'@object-ui/fields': minor
'@object-ui/i18n': minor
---

`RecipientPickerField` gains a picker mode for the `field` sharing recipient
(objectui#7613; maintainer ruling objectstack#14103, executor objectstack#15072).

`sys_sharing_rule.recipient_type` carries a sixth value, `field` — the
record-relative recipient. The recipients are whoever a user-valued column of the
SHARED object names on each matched record, so `recipient_id` stores a field
NAME (`assignees`, `owner_manager`) and not a record id. The widget's
`TYPE_TO_OBJECT` table had no entry for that kind, so it fell through to the
plain text input documented for unknown types: functional (the stored value was
already the right one), but the admin had to know and type the machine name of
the column by hand, with no list to choose from and nothing saying whether the
name existed.

When `recipient_type` is `field`, the widget now reads the shared object from
the sibling `object_name` field — the same dependency the `filter-condition`
widget already reads — loads that object's schema through
`dataSource.getObjectSchema`, and offers its **user-valued** columns, storing the
column's name.

**The offered set is exactly the set the evaluator honours**, and that agreement
is the point of the change rather than a detail of it. The sharing evaluator
reads a column as users when it is the `user` type, or a `lookup` /
`master_detail` whose `reference` is `sys_user`; anything else it treats as
"grants nobody" and warns about once per rule. A picker offering a wider set
would let an admin save a rule that looks configured and authorises nobody —
worse than a hand-typed name, because it has a credible appearance. `hidden` is
deliberately not filtered (the evaluator honours a hidden user column), and
`reference_to` is deliberately not read (the protocol refuses that spelling by
name, and so does the evaluator).

Three things the mode says out loud rather than leaving to be inferred: it asks
for the shared object before offering anything when `object_name` is still
unset; an object with no user columns says so instead of rendering a
search-flavoured "No matches"; and a stored name that is not on the offered list
— a column deleted or retyped since the rule was saved — stays visible and is
marked, because the evaluator grants nobody for it and a control that merely
looked empty would hide a rule that still names it.

Unknown recipient types keep degrading to the plain text input, and so does
`field` itself when the data source cannot enumerate an object's columns: a
hand-typed name is worse than a list, and better than a list that can never
fill.

Three copy keys are added across all ten locale packs
(`fields.recipient.selectField`, `noUserFields`, `fieldNotUserTyped`). The
"select an object first" gate deliberately reuses the criteria builder's
existing sentence rather than adding a twin: it is the same sentence in the same
role on the same form, gating on the same sibling field.
193 changes: 182 additions & 11 deletions packages/fields/src/widgets/RecipientPickerField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import { useFieldTranslation } from './useFieldTranslation.js';
* position → sys_position, store `name` (matched against
* sys_user_position.position at evaluation time)
*
* One kind is NOT a record picker and therefore has no row above:
*
* field → a user-valued COLUMN of the shared object, store
* the column's `name` (see FIELD_RECIPIENT_TYPE)
*
* When `recipient_type` changes after mount the stored id is reset (an id valid
* for one type is meaningless for another). Unknown types degrade to a plain
* text input so nothing breaks.
Expand All @@ -49,6 +54,84 @@ const TYPE_TO_OBJECT: Record<string, RecipientMapping> = {
position: { object: 'sys_position', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectPosition' },
};

/**
* The RECORD-RELATIVE recipient kind (maintainer ruling objectstack#14103,
* executor objectstack#15072). It is deliberately absent from
* `TYPE_TO_OBJECT`: there is no target object to query and no record id to
* store. `recipient_id` holds the NAME of a user-valued column on the SHARED
* object, and the recipients are whoever that column names on each matched
* record — so this mode reads the object chosen in the sibling `object_name`
* field (the same dependency the `filter-condition` widget already reads) and
* offers that object's user-valued fields.
*/
const FIELD_RECIPIENT_TYPE = 'field';

/** A user-valued column offered by the `field` recipient mode. */
interface UserFieldDef {
/** The machine name — this is what gets stored in `recipient_id`. */
name: string;
label: string;
}

/**
* Does this declared field hold USERS?
*
* ⭐ This predicate is the picker's half of a two-sided agreement with the
* sharing evaluator, which asks the same question of the same two spellings:
* the `user` type (whose target is fixed to `sys_user` by the type itself —
* `Field.user()` takes no target), and a `lookup` / `master_detail` whose
* `reference` is `sys_user`.
*
* ⛔ The two must not drift apart. The evaluator treats a column that is not
* user-typed as "grants nobody" and warns once per rule, so a picker that
* OFFERED a wider set would let an admin save a rule that looks configured and
* authorises nobody — worse than the hand-typed name this mode replaces,
* because it has a credible appearance.
*
* `reference` is the only spelling the protocol declares (objectui#6837):
* `FieldSchema` refuses `reference_to` by name, and the evaluator reads
* `reference` as well.
*
* @internal exported for tests
*/
export function fieldHoldsUsers(def: unknown): boolean {
if (!def || typeof def !== 'object') return false;
const d = def as { type?: unknown; reference?: unknown };
return (
d.type === 'user' ||
((d.type === 'lookup' || d.type === 'master_detail') && d.reference === 'sys_user')
);
}

/**
* The user-valued columns of an object schema, in declaration order.
*
* Accepts both shapes an object schema spells `fields` in — a name-keyed map
* and an array of definitions carrying their own `name`.
*
* ⛔ `hidden` is deliberately NOT filtered here, unlike the filter builder's
* own field derivation: the evaluator honours a hidden user column exactly
* like a visible one, and withholding it would break the agreement above in
* the other direction — an authorable, working configuration the picker
* refuses to offer.
*
* @internal exported for tests
*/
export function deriveUserFields(schema: any): UserFieldDef[] {
const raw = schema?.fields;
const entries: Array<[string, any]> = Array.isArray(raw)
? raw.map((f: any) => [f?.name, f])
: raw && typeof raw === 'object'
? Object.entries(raw)
: [];
const out: UserFieldDef[] = [];
for (const [name, f] of entries) {
if (!name || !fieldHoldsUsers(f)) continue;
out.push({ name: String(name), label: f.label ? String(f.label) : String(name) });
}
return out;
}

export function RecipientPickerField({
value,
onChange,
Expand All @@ -66,8 +149,17 @@ export function RecipientPickerField({
const dependentValues: Record<string, any> = (props as any).dependentValues ?? {};
const recipientType = String(dependentValues.recipient_type ?? '');
const mapping = TYPE_TO_OBJECT[recipientType];
const objectName = String(dependentValues.object_name ?? '');
const isFieldRecipient = recipientType === FIELD_RECIPIENT_TYPE;
// Only this data source can answer "which columns does that object declare?".
// Without it the mode falls through to the degraded text input below rather
// than rendering a list that can never fill — the same "nothing breaks"
// promise the header makes for an unknown type.
const canListObjectFields =
isFieldRecipient && !!dataSource && typeof dataSource.getObjectSchema === 'function';

const [records, setRecords] = React.useState<any[] | null>(null);
const [userFields, setUserFields] = React.useState<UserFieldDef[] | null>(null);

// Reset the stored recipient when the admin PICKS a different type (an id for
// a user is not a valid team/business-unit id).
Expand Down Expand Up @@ -109,6 +201,26 @@ export function RecipientPickerField({
};
}, [dataSource, mapping?.object]);

// The `field` mode's own load: the SHARED object's schema, keyed on the
// sibling `object_name`. Re-run when the admin switches object, so the
// offered columns always belong to the object the rule actually names.
React.useEffect(() => {
setUserFields(null);
if (!canListObjectFields || !objectName) return;
let cancelled = false;
(async () => {
try {
const schema = await dataSource.getObjectSchema(objectName);
if (!cancelled) setUserFields(deriveUserFields(schema));
} catch {
if (!cancelled) setUserFields([]);
}
})();
return () => {
cancelled = true;
};
}, [dataSource, objectName, canListObjectFields]);

const labelOf = (r: any): string => {
for (const f of mapping?.labelFields ?? ['name']) if (r?.[f]) return String(r[f]);
return String(r?.id ?? '');
Expand All @@ -124,6 +236,21 @@ export function RecipientPickerField({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [records, value, mapping]);

const userFieldOptions = React.useMemo(
() => (userFields ?? []).map((f) => ({ value: f.name, label: f.label })),
[userFields],
);

// DOM pass-through onto the combobox trigger — the widget's real focusable
// control (objectui#3318). `name` is withheld: the trigger is a button, not
// a submission control (same reasoning as #3306's SelectTrigger).
//
// NOTE this widget stays on the #3318 ledger regardless: its dependency-
// gated state (no `recipient_type` chosen yet — the state a fresh form and
// the registry sweep render) is a plain hint paragraph with no focusable
// control, so there is nothing there to carry the attribute.
const { name: _domName, ...triggerDomProps } = toDomProps(props);

if (!recipientType) {
return (
<p className={cn('text-sm text-muted-foreground', className)}>
Expand All @@ -132,9 +259,63 @@ export function RecipientPickerField({
);
}

if (canListObjectFields) {
if (!objectName) {
// Deliberately the SAME key the criteria builder's gate sentence uses,
// not a `fields.recipient.*` twin: it is the same sentence, in the same
// role, on the same form, gating on the same sibling field — and a
// second spelling is precisely how two copies of one deliberately
// shared sentence come to read differently in a locale.
return (
<p className={cn('text-sm text-muted-foreground', className)}>
{t('fields.filterCondition.selectObjectFirst')}
</p>
);
}

// The stored name stays VISIBLE even when it is not on the offered list —
// a column deleted or retyped since the rule was saved. Dropping it would
// leave the control looking empty while the rule still names that column;
// the evaluator grants NOBODY for it, so the option says so instead of
// reading like any other choice.
const fieldOpts =
value && !userFieldOptions.some((o) => o.value === value)
? [{ value, label: t('fields.recipient.fieldNotUserTyped', { name: value }) }, ...userFieldOptions]
: userFieldOptions;

if (readonly) {
if (!value) return <EmptyValue />;
return (
<span className={className}>{fieldOpts.find((o) => o.value === value)?.label ?? value}</span>
);
}

return (
<Combobox
{...triggerDomProps}
options={fieldOpts}
value={value ?? ''}
onValueChange={(v) => onChange(v as any)}
placeholder={
userFields === null ? t('fields.recipient.loading') : t('fields.recipient.selectField')
}
searchPlaceholder={t('fields.recipient.search')}
emptyText={
userFields === null ? t('fields.recipient.loading') : t('fields.recipient.noUserFields')
}
disabled={disabled}
className={cn('w-full', className)}
// AFTER the spread so this widget's own computation wins (#3222).
aria-invalid={!!error}
/>
);
}

if (!mapping) {
// Unknown / unsupported recipient type — keep a plain text input so the
// field is never un-editable.
// field is never un-editable. `field` reaches here too when the data
// source cannot enumerate an object's columns: a hand-typed name is worse
// than a list, and better than a list that can never fill.
return (
<input
// DOM pass-through onto the real focusable control (objectui#3318).
Expand All @@ -156,16 +337,6 @@ export function RecipientPickerField({
return <span className={className}>{options.find((o) => o.value === value)?.label ?? value}</span>;
}

// DOM pass-through onto the combobox trigger — the widget's real focusable
// control (objectui#3318). `name` is withheld: the trigger is a button, not
// a submission control (same reasoning as #3306's SelectTrigger).
//
// NOTE this widget stays on the #3318 ledger regardless: its dependency-
// gated state (no `recipient_type` chosen yet — the state a fresh form and
// the registry sweep render) is a plain hint paragraph with no focusable
// control, so there is nothing there to carry the attribute.
const { name: _domName, ...triggerDomProps } = toDomProps(props);

return (
<Combobox
{...triggerDomProps}
Expand Down
Loading
Loading