Skip to content

feat(fields): give RecipientPickerField a picker mode for the field sharing recipient (objectui#7613) - #10049

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-7613-field-recipient-picker
Sep 20, 2026
Merged

os-tesla merged 1 commit into
mainfrom
claude/issue-7613-field-recipient-picker

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #7613

Clause-②: no

sys_sharing_rule.recipient_type carries a sixth value, field — the record-relative recipient (maintainer ruling objectstack#14103, executor objectstack#15072). Its recipient_id stores a field NAME (assignees, owner_manager) and not a record id, so the kind has no row in TYPE_TO_OBJECT and fell through to the plain text input the widget header documents for unknown types. The value that input stored was already the right one; what was missing was any list of the object's user-valued columns, and any signal that a hand-typed name exists.

When recipient_type is field the widget now reads the shared object from the sibling object_name — 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 one hard agreement — checked at the runtime, not taken from the card

The card's filter and the runtime's judgement agree, and this was verified against the executor's source in the objectstack checkout rather than assumed. packages/plugins/plugin-sharing/src/sharing-rule-service.ts declares a fieldHoldsUsers helper whose rule is, in words: a declared field holds users when its type is user, or when its type is lookup or master_detail and its reference is sys_user. Its caller fieldRecipientColumnIsUsable answers false for anything else and emits the one-per-rule warning that begins "active field-recipient rule grants NOBODY". That is the card's sentence, word for word in behaviour.

So the picker's predicate is the same predicate, and two corollaries follow from the agreement rather than from taste:

  • hidden is deliberately NOT filtered, unlike the criteria builder's own field derivation. The evaluator honours a hidden user column exactly like a visible one, so withholding it would break the agreement in the other direction — an authorable, working configuration the picker refuses to offer.
  • reference_to is deliberately NOT read. The protocol refuses that spelling by name (objectui#6837) and the evaluator reads reference only, so a lookup declaring reference_to: sys_user is not a user column on either side.

Three states the mode names out loud

  • object_name still unset: it asks for the shared object instead of rendering a list it cannot fill.
  • The object declares no user columns: it says so, rather than the search-flavoured "No matches" the record kinds use. An empty list here is not a failed search, and an admin not told which it is cannot act.
  • The stored name is not on the offered list — a column deleted or retyped since the rule was saved: it stays visible and marked. The evaluator grants nobody for such a rule, and a control that merely looked empty would hide a rule that still names that column. This is the failure mode triage called worse than a hand-typed name: a rule with a credible appearance that authorises nobody.

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

Why dependentValues.object_name is actually reachable

Checked, because the card asserts it and the upstream metadata does not obviously grant it: sys-sharing-rule.object.ts declares recipient_id with dependsOn: ['recipient_type'] and does not name object_name. That turns out not to matter for this widget: the form renderer passes dependentValues: ruleRecord, and ruleRecord is the WHOLE watched form record (every declared field seeded, the persisted record under it, live form.watch() values over the top) rather than a dependsOn-scoped slice, and dependsOn itself is stripped from the widget's props. So object_name is present and live today. Widening the upstream declaration is still worth doing for honesty, and is named in the acceptance notes below rather than done here.

i18n

Three keys, added to the provider-less defaults table in packages/fields and to all ten packs in packages/i18n: fields.recipient.selectField, fields.recipient.noUserFields, fields.recipient.fieldNotUserTyped (one hole, spelled the one way the provider-less fallback resolves).

The "select an object first" gate reuses the criteria builder's existing sentence rather than adding a fields.recipient.* twin. It is the same sentence, in the same role, on the same form, gating on the same sibling field, and this repo's own defaults table records the measured reason not to twin such a sentence — two copies of one deliberately shared sentence are how they come to read differently in a locale. Flagged here because it is a judgement call a reviewer may want to reverse; reversing it is three more rows in ten packs and nothing else.

Tests — red before, green after

New: packages/fields/src/widgets/__tests__/RecipientPickerField.fieldRecipient-7613.test.tsx, 13 pins. The schema fixture carries three columns the evaluator honours and four it refuses in one object, so every zero in the file travels with a same-subject control lit by the same command.

Three ablations, each run through ablation-replace so the mutation is proven on disk (anchor count, replacement count and the git hash-object blob all move) and the restore is proven against HEAD (blob equality plus an empty git diff HEAD):

ablation what was broken result
widen the predicate (d.reference === 'sys_user' replaced) the agreement 4 failed / 9 passed — blob a90a0e95f380 to fac0c726efb2
disable the field branch (if (canListObjectFields) gated off) the mode itself 6 failed / 7 passed — blob a90a0e95f380 to 51830498522d
restore the whole widget to base e86445f57 everything 12 failed / 13 — blob a90a0e95f380 to 062ac5b30875, equal to the base blob

The 13th pin in that last row is green on base by construction and is reported as such rather than glossed: it is the "degrades to the plain text input when the data source cannot list columns" pin, i.e. the pre-existing behaviour this change preserves. Every ablation restored to the committed blob a90a0e95f380 with git diff HEAD empty.

Commands and exit codes

All measurements taken after the last edit, on d547d4217. Heavy runs went through the shared verify lock in the sibling checkout.

command exit
pnpm exec vitest run packages/fields/ packages/i18n/ — 241 files, 4102 tests passed 0
pnpm --filter @object-ui/fields --filter @object-ui/i18n run type-check 0
pnpm --filter @object-ui/fields --filter @object-ui/i18n run lint — 0 errors 0
pnpm check:i18n-keys 0
pnpm check:i18n-drift — "0 en value(s) changed (3 key(s) added, 0 removed)" 0
pnpm check:i18n-dead-keys 0
pnpm check:control-bytes 0
pnpm check:new-line-citations — 0 new citations 0
node scripts/check-changeset-presence.mjs — 12 source files, 1 changeset 0
node scripts/check-changeset-no-major.mjs 0
node scripts/check-governed-queue-guard.mjs --test ... — NOT GOVERNED, 14 paths 0

Every exit code was captured by redirecting to a file first and reading the status before any pipe.

Declared narrowing: the repo-wide turbo run lint / turbo run type-check / whole-suite pnpm test runs were not taken locally; they are CI's. What was taken locally is the two affected packages' own lint, type-check and full test suites, plus the gate scripts above. @object-ui/fields' published surface gains two exports (fieldHoldsUsers, deriveUserFields, both marked internal-for-tests in the same style the sibling FilterConditionField already uses) and loses none, so no importer's behaviour changes.

Acceptance notes — out of scope, not fixed here

  • sys-sharing-rule.object.ts declares recipient_id with dependsOn: ['recipient_type'] only. The widget reads object_name too, and gets it because the renderer hands over the whole watched record rather than a scoped slice. The declaration is therefore an honest-metadata gap, not a defect: it understates what the widget reads, and a future renderer that did scope dependentValues by dependsOn would break this mode silently. It belongs in the objectstack repo and is named as a card to file, not carried here.
  • Switching object_name while recipient_type is field leaves the stored column name in place. It is now visible and marked when it is not a user column of the new object, which is the safe half; auto-clearing it the way a recipient_type switch does was weighed and not taken, because the same widget's own history has a wipe-on-hydration regression (a stored recipient cleared the moment an existing rule was opened for editing) and a second value-clearing effect on a second dependency is the shape that caused it. Noted, not filed: the marking covers the authoring hazard, and the carrier for a change here would be this same widget.
  • Noted, not filed: deriveFilterFields in FilterConditionField and deriveUserFields here both normalise the two shapes of fields (array and name-keyed map) with the same eight lines. A shared helper is a reasonable cleanup for whichever PR next touches both; carrier today: none.

Parallel work: objectui#9954 is in flight on packages/app-shell/src/, disjoint from this file face. The dependency edge runs app-shell to @object-ui/fields and not back, so if this lands first that card owes a re-verification and this one owes it nothing.

This PR is left as a draft on purpose; the claiming seat lands it.

Session: session_018HrVaotisyhgmot9o2MLRq


Generated by Claude Code

… recipient

`sys_sharing_rule.recipient_type` carries a sixth value, `field` — the
record-relative recipient (maintainer ruling objectstack#14103, executor
objectstack#15072). Its `recipient_id` stores a field NAME, not a record
id, so the kind has no row in `TYPE_TO_OBJECT` and fell through to the
plain text input the header documents for unknown types: the stored value
was already correct, but the admin had to know and type the machine name
of the column by hand, with no list and no signal that the name existed.

The widget now reads the shared object from the sibling `object_name`
(the same dependency the `filter-condition` widget already reads), loads
its schema through `dataSource.getObjectSchema`, and offers that object's
user-valued columns, storing the column's name.

The offered set is exactly the set the evaluator honours: the `user`
type, or a `lookup` / `master_detail` whose `reference` is `sys_user`.
Anything else the evaluator reads as "grants nobody" plus one warning per
rule, so a wider picker would produce a rule that looks configured and
authorises nobody. `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 states the mode names instead of leaving to be inferred: it asks
for the shared object while `object_name` is unset; an object with no
user columns says so rather than rendering a search-flavoured "No
matches"; and a stored name that is not on the offered list stays visible
and is marked, because the evaluator grants nobody for it.

Unknown types keep degrading to the text input, and so does `field`
itself when the data source cannot enumerate columns.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HrVaotisyhgmot9o2MLRq
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 2 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6661-app-launcher-nav-menu-renderers.md

  • names en.tspackages/i18n/src/locales/en.ts — edited by this change

    Three new strings — the launcher's and the menu's accessible names, and the menu's empty state — are declared under console.nav in en.ts and its nine sibling packs. An inline defaultValue alone is not a fix: it renders English at one call site and leaves the string untranslatable everywhere (objectui#3517).

.changeset/7122-capability-manage-org-presentation.md

  • names useFieldTranslation.tspackages/fields/src/widgets/useFieldTranslation.ts — edited by this change

    The label is the spec artifact's own (Manage Organization Presentation), read off the installed build rather than invented, and it is authored everywhere the widget's docblock requires of any edit to that list: useFieldTranslation.ts and all ten locale packs. Each non-English string is composed from that pack's own established sibling vocabulary (manage_org_users, manage_platform_settings) rather than machine-translated; a native review pass is welcome on the nine, and nothing about the capability's behaviour depends on the wording.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with e86445f57 (merge-base with origin/main): 13 file(s) changed outside .changeset/, read against 1221 pending declaration(s) that publish a body (1781 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 329 chunks) 3057.3 KB 3104.5 KB
Main entry chunk (gzip) 145.9 KB 350 KB
Entry file index-CaEqGzhD.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 545.92KB 130.72KB
core (index.js) 8.94KB 3.59KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 221.99KB 61.72KB
fields (index.js) 251.67KB 63.42KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.90KB 10.97KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 50.26KB 14.36KB
plugin-charts (index.js) 71.73KB 20.08KB
plugin-chatbot (index.js) 198.20KB 47.14KB
plugin-dashboard (index.js) 132.96KB 35.17KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 255.18KB 66.49KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 139.56KB 35.40KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 213.44KB 58.21KB
plugin-kanban (index.js) 48.71KB 15.17KB
plugin-list (index.js) 113.53KB 27.99KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.68KB 8.95KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 85.18KB 21.05KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 109.04KB 36.08KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.38KB 1.98KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.74KB 2.54KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 15.71KB 5.30KB
types (ai.js) 4.11KB 2.06KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RecipientPickerField: no picker mode for the field sharing recipient — an admin has to type the field name into the degraded text input

2 participants