From 576f91320e16abf5210420606c571d7d15b0ed5a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:46:37 +0000 Subject: [PATCH 1/7] =?UTF-8?q?feat(ui):=20queue=20pickers=20read=20both?= =?UTF-8?q?=20sources=20=E2=80=94=20the=20approver=20value=20and=20the=20s?= =?UTF-8?q?haring-rule=20recipient=20(ADR-0142=20D6/D7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz Co-Authored-By: Claude --- .../src/utils/approverValueSources.ts | 37 +++ .../inspectors/FlowReferenceField.tsx | 229 +++++++++++++++++- .../inspectors/flow-node-config.ts | 8 +- .../widget-ref-cells/WidgetRefCellFaces.tsx | 5 +- .../widget-ref-cells/refLabelDirectory.ts | 35 ++- ui/packages/fields/src/index.tsx | 4 + .../src/widgets/RecipientPickerField.tsx | 47 +++- .../fields/src/widgets/queueCandidates.ts | 81 +++++++ .../fields/src/widgets/recipientKinds.ts | 28 +++ .../fields/src/widgets/useFieldTranslation.ts | 1 + ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 1 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + ui/packages/i18n/src/namespaces/studio/en.ts | 1 + ui/packages/i18n/src/namespaces/studio/zh.ts | 1 + 22 files changed, 471 insertions(+), 16 deletions(-) create mode 100644 ui/packages/fields/src/widgets/queueCandidates.ts diff --git a/ui/packages/app-shell/src/utils/approverValueSources.ts b/ui/packages/app-shell/src/utils/approverValueSources.ts index 3cc3dca2e08..ae1c41fe95b 100644 --- a/ui/packages/app-shell/src/utils/approverValueSources.ts +++ b/ui/packages/app-shell/src/utils/approverValueSources.ts @@ -67,6 +67,30 @@ export function registryValueSourceOf(raw: unknown): RegistryValueSource | undef : undefined; } +/** + * The two-source member of the spec's approver value binding (ADR-0142 D6): + * the value is one machine name that resolves among the items of metadata + * `type` AND the organization's rows of `object`, committed as `valueField`. + * `queue` is bound that way. + */ +export type RegistryAndDataValueSource = Extract; + +/** + * Read one binding as the two-source member, or `undefined`. An entry missing + * either half is not this member: a picker that lists one source would offer + * half of what the value can name. + */ +export function registryAndDataValueSourceOf(raw: unknown): RegistryAndDataValueSource | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const s = raw as { source?: unknown; type?: unknown; object?: unknown; valueField?: unknown }; + return s.source === 'registry-and-data' + && typeof s.type === 'string' && s.type + && typeof s.object === 'string' && s.object + && typeof s.valueField === 'string' && s.valueField + ? { source: 'registry-and-data', type: s.type, object: s.object, valueField: s.valueField } + : undefined; +} + /** Approver kind → the metadata type its value names, for every registry-bound kind in `sources`. */ export function registryTypesOf(sources: Readonly>): Record { return Object.fromEntries( @@ -84,3 +108,16 @@ export function registryTypesOf(sources: Readonly>): Rec */ export const APPROVER_REGISTRY_TYPES: Readonly> = registryTypesOf(APPROVER_VALUE_SOURCES); + +/** + * The two-source approver kinds of the bundled spec, kind → binding — + * `{ queue: { type: 'queue', object: 'sys_queue', valueField: 'name' } }` on a + * spec carrying ADR-0142 D6. Read off the published binding, never listed here. + */ +export const APPROVER_TWO_SOURCE_BINDINGS: Readonly> = + Object.fromEntries( + Object.entries(APPROVER_VALUE_SOURCES).flatMap(([kind, source]) => { + const twoSource = registryAndDataValueSourceOf(source); + return twoSource ? [[kind, twoSource]] : []; + }), + ); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx index 4f54c749178..ccb6dd4b629 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.tsx @@ -18,7 +18,9 @@ * `position`) render a record lookup against the data API (with a manual-entry * escape hatch, so the never-trap rule still holds), `org-membership-level` is * a strict select over a closed enum, `manager` is auto-resolved (disabled - * cell), and `queue` is free text naming the queue (ADR-0142 D6). A kind whose + * cell), and `queue` renders a select over BOTH of a queue's sources — the + * declared queues and the organization's `sys_queue` rows (ADR-0142 D6, + * `{ source: 'registry-and-data' }`), with the same escape hatch. A kind whose * published binding is the REGISTRY instead (`{ source: 'registry', type }`, * objectstack#22710 — `position` on a server carrying it) renders a select over * that metadata type's items and commits the item's name (objectui#7611 Q3b), @@ -50,15 +52,19 @@ import { Pencil, Search } from 'lucide-react'; import { APPROVER_VALUE_SOURCES } from '@objectstack/spec/automation'; import { BUILTIN_MEMBERSHIP_ROLE_OPTIONS } from '@objectstack/spec/identity'; import { useAdapter } from '@object-ui/react'; -import { LookupField } from '@object-ui/fields'; -import type { FormField, LookupFieldMetadata } from '@object-ui/types'; +import { LookupField, mergeQueueCandidates, queueCandidateLabel, type QueueCandidate } from '@object-ui/fields'; +import type { DataSource, FormField, LookupFieldMetadata } from '@object-ui/types'; import type { FlowReferenceSpec, ReferenceKind, RefValueSource } from './flow-node-config.js'; import { useMetadataClient } from '../useMetadata.js'; import { useObjectFields } from '../previews/useObjectFields.js'; import { t, tFormat, useMetadataLocale, type SupportedLocale } from '../i18n.js'; import { flagUnknownValue, RequiredMarker } from './_shared.js'; import { ObjectPicker } from './ObjectPicker.js'; -import { APPROVER_REGISTRY_TYPES } from '../../../utils/approverValueSources.js'; +import { + APPROVER_REGISTRY_TYPES, + APPROVER_TWO_SOURCE_BINDINGS, + type RegistryAndDataValueSource, +} from '../../../utils/approverValueSources.js'; import { isStudioHiddenSystemField } from '../../studio-design/studioHiddenSystemField.js'; /** Context the reference picker needs to resolve dynamic option sources. */ @@ -357,6 +363,30 @@ export function registryTypeFor(resolved: ResolvedRef | undefined): string | und return KIND_TO_REGISTRY_TYPE[resolved.kind]; } +/** + * The older-server fallback for a TWO-SOURCE kind (ADR-0142 D6), derived from + * the bundled spec exactly as {@link KIND_TO_REGISTRY_TYPE} is: + * `{ queue: { type: 'queue', object: 'sys_queue', valueField: 'name' } }`. + * Never a list written here. + */ +export const KIND_TO_TWO_SOURCE: Readonly>> = + APPROVER_TWO_SOURCE_BINDINGS; + +/** + * The two-source binding a resolved reference lists its candidates from + * (`{ source: 'registry-and-data', type, object, valueField }`, ADR-0142 D6), + * else `undefined`. The same precedence as {@link registryTypeFor}: the + * server's published `xRef.sources` entry wins, and a kind it publishes as + * anything else never reaches this picker; with no published source the + * spec-derived {@link KIND_TO_TWO_SOURCE} answers. + */ +export function twoSourceFor(resolved: ResolvedRef | undefined): RegistryAndDataValueSource | undefined { + if (!resolved) return undefined; + const published = resolved.source; + if (published) return published.source === 'registry-and-data' ? published : undefined; + return KIND_TO_TWO_SOURCE[resolved.kind]; +} + /** Read `node.config[key]` as a non-empty string, else undefined. */ function configString(node: Record | null | undefined, key: string): string | undefined { const cfg = node?.config; @@ -761,6 +791,169 @@ function RegistryPickCell({ type, value, onPick, onCommit, onBlur, disabled, pla ); } +/** + * Both of a two-source binding's lists, merged into one by machine name + * (ADR-0142 D1): the declared items of `binding.type` through the designer's + * metadata client (`GET /api/v1/meta/`) and the organization's rows of + * `binding.object` through the data adapter. `mergeQueueCandidates` is the one + * merge the sharing-rule recipient picker reads too, so the two pickers offer + * the same queues. The committed value is the candidate's `name`, which is the + * binding's `valueField` by the spec's own type. + * + * `enabled === false` (no adapter) reads nothing: one source alone would offer + * half of what the value can name. A failed half contributes nothing and the + * other is still offered. + */ +function useTwoSourceCandidates( + binding: RegistryAndDataValueSource, + adapter: DataSource | null, +): { candidates: QueueCandidate[]; loading: boolean } { + const client = useMetadataClient(); + const enabled = !!adapter && typeof adapter.find === 'function'; + const [state, setState] = React.useState<{ candidates: QueueCandidate[]; loading: boolean }>({ + candidates: [], + loading: enabled, + }); + const { type, object, valueField } = binding; + React.useEffect(() => { + if (!enabled || !adapter) { + setState({ candidates: [], loading: false }); + return; + } + let cancelled = false; + setState((s) => ({ ...s, loading: true })); + Promise.allSettled([ + client.list<{ name?: string; label?: string }>(type), + adapter.find(object, { $top: 500, $orderby: { [valueField]: 'asc' } }), + ]).then(([declared, rows]) => { + if (cancelled) return; + setState({ + candidates: mergeQueueCandidates( + declared.status === 'fulfilled' ? declared.value : [], + rows.status === 'fulfilled' ? rows.value : [], + ), + loading: false, + }); + }); + return () => { + cancelled = true; + }; + }, [client, adapter, enabled, type, object, valueField]); + return state; +} + +/** + * A single-select picker cell for a TWO-SOURCE reference (ADR-0142 D6): the + * value is a queue machine name, and a queue is either declared by a package + * or authored by the organization as a `sys_queue` row, so the candidates are + * both lists merged by {@link useTwoSourceCandidates}. A choice commits the + * name. + * + * "Never trap the author" holds as it does for {@link RegistryPickCell}: with + * no adapter (offline preview) the cell is the plain text box, a pencil toggle + * switches to manual entry, an empty list leaves the text box, and a stored + * name neither source carries still renders, flagged `(not found)`. The queue + * hint says what the name resolves to, under every mode. + */ +function TwoSourcePickCell({ binding, value, onPick, onCommit, onBlur, disabled, placeholder, showHint, locale }: { + binding: RegistryAndDataValueSource; + value: unknown; + /** Immediate commit for a choice (there is no blur to flush on). */ + onPick: (value: string) => void; + /** Keystroke-level commit for the manual-entry input (flushed via onBlur). */ + onCommit: (value: unknown) => void; + onBlur?: () => void; + disabled?: boolean; + placeholder?: string; + showHint: boolean; + /** The designer locale the toggle buttons, the flag and the hint are worded in. */ + locale: SupportedLocale; +}) { + const adapter = useAdapter(); + const { candidates, loading } = useTwoSourceCandidates(binding, adapter); + const [manual, setManual] = React.useState(false); + const current = value != null ? String(value) : ''; + const listed = !loading && candidates.length > 0; + const hint = showHint ? ( +

+ {t('engine.inspector.reference.queueHint', locale)} +

+ ) : null; + + if (manual || !listed) { + return ( +
+
+ onCommit(e.target.value)} + onBlur={onBlur} + onKeyDown={(e) => { + if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); + }} + placeholder={loading ? t('engine.form.loadingOptions', locale) : placeholder} + disabled={disabled || loading} + className="h-8 flex-1 text-sm" + /> + {listed && ( + + )} +
+ {hint} +
+ ); + } + + const options: Option[] = candidates.map((c) => ({ value: c.name, label: queueCandidateLabel(c) })); + const shown = current && !options.some((o) => o.value === current) + ? [...options, { value: current, label: flagUnknownValue(current, t('engine.form.notFound', locale), locale) }] + : options; + return ( +
+
+
+ +
+ +
+ {hint} +
+ ); +} + export interface ReferenceComboboxProps { /** The resolved concrete reference, or undefined → plain free text. */ resolved: ResolvedRef | undefined; @@ -793,7 +986,8 @@ export interface ReferenceComboboxProps { * directory-backed kinds render a record lookup ({@link RecordLookupCell}), * registry-bound kinds a select over the registry ({@link RegistryPickCell}), * `org-membership-level` a strict select (closed enum), `manager` a disabled - * auto-resolved cell, and `queue` free text naming the queue (ADR-0142 D6). + * auto-resolved cell, and two-source kinds (`queue`, ADR-0142 D6) a select over + * both of their sources ({@link TwoSourcePickCell}). * Hooks are called unconditionally (kind-gated args) so the * component is safe to use in a repeater where the kind changes per row. */ @@ -912,6 +1106,25 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, ); } + // Two-source kinds → a select over the declared items AND the organization's + // rows (ADR-0142 D6). Read off the published binding like the two above. + const twoSource = twoSourceFor(resolved); + if (twoSource) { + return ( + + ); + } + // Closed enum → strict select, never free text: `sales_manager` typed into // a membership-tier box matches nobody at runtime. The vocabulary comes from // the SERVER when it publishes one and from the spec-derived fallback @@ -962,9 +1175,9 @@ export function ReferenceCombobox({ resolved, value, onCommit, onBlur, onSelect, } // [ADR-0142 D6] A queue approver resolves to the queue's members when the - // step opens. Its value is a queue machine name with TWO sources — a declared - // queue and an organization's own `sys_queue` row — so the cell stays free - // text until the console's two-source queue picker lands. + // step opens. A server that publishes the two-source binding reached the + // picker above; this text box is for one that publishes `queue` as anything + // else, so a stored name stays editable. if (kind === 'queue') { return (
diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts index 230fb1e40ff..f03c082e1df 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/flow-node-config.ts @@ -31,7 +31,7 @@ import type { FlowTriggerKind } from '@objectstack/spec/automation'; import type { DeclaredConfigKey } from './json-schema-to-fields.js'; -import type { RegistryValueSource } from '../../../utils/approverValueSources.js'; +import type { RegistryAndDataValueSource, RegistryValueSource } from '../../../utils/approverValueSources.js'; import { flowFieldZh, isZhLocale } from '../i18n.js'; export type FlowConfigFieldKind = @@ -171,7 +171,10 @@ export interface FlowReferenceSpec { * `registry` is the other half of that split (objectstack#22710, objectui#7611 * Q3b): the value is the machine name of a declared item of metadata `type`, so * its candidates ARE the registry, `GET /api/v1/meta/`, and the picker - * commits the item's `name` — `position` is bound this way (ADR-0131 D4). The + * commits the item's `name` — `position` is bound this way (ADR-0131 D4). + * `registry-and-data` is both at once (ADR-0142 D6): one name that resolves + * among the declared items of `type` AND the organization's rows of `object`, + * so the picker lists both — `queue` is bound this way. The * other variants are not pickers at all: a closed `enum`, an `auto`-resolved * value, a `trigger-field` name, a CEL `expression`, or an `unsupported` type * the runtime never resolves. @@ -179,6 +182,7 @@ export interface FlowReferenceSpec { export type RefValueSource = | { source: 'data'; object: string; valueField: string } | RegistryValueSource + | RegistryAndDataValueSource | { source: 'enum'; values: string[] } | { source: 'auto' | 'trigger-field' | 'expression' | 'unsupported' }; diff --git a/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx b/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx index c6594c1d372..cbfd50e2fa1 100644 --- a/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx +++ b/ui/packages/app-shell/src/views/widget-ref-cells/WidgetRefCellFaces.tsx @@ -68,8 +68,9 @@ export function ObjectRefCellFace({ value, field }: CellRendererProps) { } /** - * `recipient-picker`: the label of the user, team, business unit, position or - * user column the stored value names — the label the picker shows for it. + * `recipient-picker`: the label of the user, team, business unit, position, + * queue or user column the stored value names — the label the picker shows for + * it. * Which of those it names is the row's `recipient_type`, so the face reads * `dependentValues`; without them it draws the stored text as the text cell * does. diff --git a/ui/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts b/ui/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts index 83e934f1fcf..e362e82da9e 100644 --- a/ui/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts +++ b/ui/packages/app-shell/src/views/widget-ref-cells/refLabelDirectory.ts @@ -29,6 +29,12 @@ * - a registry kind (`position`, `RECIPIENT_REGISTRY_KINDS`) → the metadata * store, `ensureType()`, the reader `RecipientPickerField` lists it * with; + * - a two-source kind (`queue`, `RECIPIENT_REGISTRY_AND_RECORD_KINDS`, + * ADR-0142 D7) → both lists the picker reads, the metadata store's + * `ensureType()` and the organization's rows of ``, merged by + * `mergeQueueCandidates`, the picker's own merge. A catalog read like a + * registry kind's, so it carries the picker's 500-row cap on the rows: a + * queue past it is `unresolved` and draws its stored name; * - the `field` kind → the shared object's schema through * `deriveUserFields`, the picker's own column derivation. * @@ -78,8 +84,10 @@ import type { DataSource } from '@object-ui/types'; import { FIELD_RECIPIENT_TYPE, RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_AND_RECORD_KINDS, RECIPIENT_REGISTRY_KINDS, deriveUserFields, + mergeQueueCandidates, recipientCandidateLabel, } from '@object-ui/fields'; @@ -301,6 +309,7 @@ export function useRecipientLabel( const objectName = String(dependentValues?.object_name ?? ''); const record = RECIPIENT_RECORD_KINDS[kind]; const registry = RECIPIENT_REGISTRY_KINDS[kind]; + const twoSource = RECIPIENT_REGISTRY_AND_RECORD_KINDS[kind]; const id = dependentValues && typeof value === 'string' ? value : ''; const key = !id ? '' @@ -308,9 +317,14 @@ export function useRecipientLabel( ? typeof dataSource?.find === 'function' ? JSON.stringify(['record', kind, id]) : '' : registry ? store.present ? JSON.stringify(['registry', registry.type, id]) : '' - : kind === FIELD_RECIPIENT_TYPE && objectName && typeof dataSource?.getObjectSchema === 'function' - ? JSON.stringify(['field', objectName, id]) - : ''; + : twoSource + // Both readers or none, as the picker requires them. + ? store.present && typeof dataSource?.find === 'function' + ? JSON.stringify(['two-source', twoSource.type, id]) + : '' + : kind === FIELD_RECIPIENT_TYPE && objectName && typeof dataSource?.getObjectSchema === 'function' + ? JSON.stringify(['field', objectName, id]) + : ''; const entry = useBookEntry(dataSource, key, (book) => { if (record) return book.askRecord(kind, id, key); if (registry) { @@ -325,6 +339,21 @@ export function useRecipientLabel( return map; }); } + if (twoSource) { + return book.ask(`two-source:${twoSource.type}`, id, key, async () => { + const [declared, rows] = await Promise.allSettled([ + store.ref.current?.ensureType(twoSource.type) ?? Promise.resolve([]), + dataSource ? dataSource.find(twoSource.object, { $top: 500, $orderby: { name: 'asc' } }) : Promise.resolve([]), + ]); + const map = new Map(); + const merged = mergeQueueCandidates( + declared.status === 'fulfilled' ? declared.value : [], + rows.status === 'fulfilled' ? rows.value : [], + ); + for (const c of merged) map.set(c.name, { label: c.label }); + return map; + }); + } book.ask(`field:${objectName}`, id, key, async () => { const schema = dataSource ? await dataSource.getObjectSchema(objectName) : null; return new Map(deriveUserFields(schema).map((f) => [f.name, { label: f.label }])); diff --git a/ui/packages/fields/src/index.tsx b/ui/packages/fields/src/index.tsx index 04cd12d255d..63f81654117 100644 --- a/ui/packages/fields/src/index.tsx +++ b/ui/packages/fields/src/index.tsx @@ -5229,9 +5229,13 @@ export { objectRefOptionLabel } from './widgets/objectRefLabel.js'; export { RECIPIENT_RECORD_KINDS, RECIPIENT_REGISTRY_KINDS, + RECIPIENT_REGISTRY_AND_RECORD_KINDS, FIELD_RECIPIENT_TYPE, recipientCandidateLabel, } from './widgets/recipientKinds.js'; +// The one two-source queue list (ADR-0142 D1): the recipient picker, its read +// face and the flow designer's approver picker all build it here. +export { mergeQueueCandidates, queueCandidateLabel, type QueueCandidate } from './widgets/queueCandidates.js'; export * from './widgets/RecordPickerDialog.js'; // Shared picker-column derivation (ADR-0085 highlightFields → displayFields → // schema walk) — consumed by LookupField AND by RelatedList's Add-picker so a diff --git a/ui/packages/fields/src/widgets/RecipientPickerField.tsx b/ui/packages/fields/src/widgets/RecipientPickerField.tsx index e374a7916d5..803d5a18c56 100644 --- a/ui/packages/fields/src/widgets/RecipientPickerField.tsx +++ b/ui/packages/fields/src/widgets/RecipientPickerField.tsx @@ -9,9 +9,11 @@ import { useFieldTranslation } from './useFieldTranslation.js'; import { FIELD_RECIPIENT_TYPE, RECIPIENT_RECORD_KINDS, + RECIPIENT_REGISTRY_AND_RECORD_KINDS, RECIPIENT_REGISTRY_KINDS, recipientCandidateLabel, } from './recipientKinds.js'; +import { mergeQueueCandidates } from './queueCandidates.js'; /** * RecipientPickerField — dependent record picker for a polymorphic recipient @@ -36,6 +38,12 @@ import { * * position → the `position` registry, store `name` * + * One kind lists BOTH sources under one name (ADR-0142 D7): a queue a package + * declares and one the organization authored as a `sys_queue` row, merged by + * `mergeQueueCandidates` (declared first, as the server resolves a name): + * + * queue → the `queue` registry + sys_queue rows, store `name` + * * One kind is NOT a record picker and therefore has no row above: * * field → a user-valued COLUMN of the shared object, store @@ -134,8 +142,15 @@ export function RecipientPickerField({ // different facts, and only the second degrades to the text input below. const metadataStore = React.useContext(MetadataCtx); const registryMapping = metadataStore ? RECIPIENT_REGISTRY_KINDS[recipientType] : undefined; + // The two-source kind needs BOTH readers: with either one absent the list + // would silently offer half the queues, so it degrades to the text input + // below instead, like an unknown kind. + const twoSourceMapping = + metadataStore && dataSource && typeof dataSource.find === 'function' + ? RECIPIENT_REGISTRY_AND_RECORD_KINDS[recipientType] + : undefined; const mapping: { storeField: 'id' | 'name'; labelFields: string[]; placeholderKey: string } | undefined = - recordMapping ?? registryMapping; + recordMapping ?? registryMapping ?? twoSourceMapping; const objectName = String(dependentValues.object_name ?? ''); const isFieldRecipient = recipientType === FIELD_RECIPIENT_TYPE; // Only this data source can answer "which columns does that object declare?". @@ -218,6 +233,36 @@ export function RecipientPickerField({ }; }, [registryType]); + // The two-source kinds' load (ADR-0142 D7): the registry's declared items + // and the organization's rows, read together and merged by name. Keyed on + // the type and the object alone, reading the store through the same ref as + // the registry load above (AGENTS.md #10). A half that fails contributes + // nothing and the other half is still offered; the stored name stays + // visible either way (see `options`). + const twoSourceType = twoSourceMapping?.type; + const twoSourceObject = twoSourceMapping?.object; + React.useEffect(() => { + const store = metadataStoreRef.current; + if (!twoSourceType || !twoSourceObject || !store || !dataSource) return; + setRecords(null); + let cancelled = false; + Promise.allSettled([ + store.ensureType(twoSourceType), + dataSource.find(twoSourceObject, { $top: 500, $orderby: { name: 'asc' } }), + ]).then(([declared, rows]) => { + if (cancelled) return; + setRecords( + mergeQueueCandidates( + declared.status === 'fulfilled' ? declared.value : [], + rows.status === 'fulfilled' ? rows.value : [], + ), + ); + }); + return () => { + cancelled = true; + }; + }, [dataSource, twoSourceType, twoSourceObject]); + // 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. diff --git a/ui/packages/fields/src/widgets/queueCandidates.ts b/ui/packages/fields/src/widgets/queueCandidates.ts new file mode 100644 index 00000000000..e9667ad9aa9 --- /dev/null +++ b/ui/packages/fields/src/widgets/queueCandidates.ts @@ -0,0 +1,81 @@ +/** + * 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. + */ + +/** + * The candidates of a queue picker: one list built from the TWO sources a + * queue has (ADR-0142 D1, regime C). + * + * A queue is referenced everywhere by its machine name, and the name resolves + * in two places: a package DECLARES queues (the `queue` metadata type, listed + * by `GET /api/v1/meta/queue`) and an organization AUTHORS its own as + * `sys_queue` rows (read through the data API). A picker that read only one of + * them would offer half the queues an approver or a sharing rule can name, so + * every queue picker builds its list here, from both reads. + * + * The server resolves a name registry-first and refuses a `sys_queue` row that + * takes a declared queue's name, so a collision is not expected; if one is + * read anyway, the declared item wins here too, as it does on the server. + * + * Three readers: the sharing-rule recipient picker (`RecipientPickerField`), + * the read face that names a stored recipient (`@object-ui/app-shell`'s widget + * reference cells), and the flow designer's approver value picker + * (`@object-ui/app-shell`'s `FlowReferenceField`). No React here. + */ + +/** One queue a picker offers. */ +export interface QueueCandidate { + /** The machine name — what every reference stores. */ + name: string; + /** The display label, or the name when the queue has none. */ + label: string; + /** Where the queue lives: a package declaration or the organization's own row. */ + source: 'declared' | 'organization'; +} + +function rowsOf(read: unknown): unknown[] { + if (Array.isArray(read)) return read; + const r = read as { data?: unknown; records?: unknown } | null | undefined; + if (Array.isArray(r?.data)) return r!.data as unknown[]; + if (Array.isArray(r?.records)) return r!.records as unknown[]; + return []; +} + +function candidateOf(row: unknown, source: QueueCandidate['source']): QueueCandidate | undefined { + const r = row as { name?: unknown; label?: unknown } | null | undefined; + if (typeof r?.name !== 'string' || !r.name) return undefined; + const label = typeof r.label === 'string' && r.label ? r.label : r.name; + return { name: r.name, label, source }; +} + +/** + * Merge the declared queues and the organization's `sys_queue` rows into one + * list, ordered by machine name. `declared` is the registry's answer (an item + * array); `rows` is the data API's answer in any of the shapes a data source + * returns (`{ data }`, `{ records }` or a bare array). A row without a name is + * skipped: it names nothing a reference could store. + */ +export function mergeQueueCandidates(declared: unknown, rows: unknown): QueueCandidate[] { + const byName = new Map(); + for (const item of rowsOf(declared)) { + const c = candidateOf(item, 'declared'); + if (c && !byName.has(c.name)) byName.set(c.name, c); + } + for (const row of rowsOf(rows)) { + const c = candidateOf(row, 'organization'); + if (c && !byName.has(c.name)) byName.set(c.name, c); + } + return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); +} + +/** + * The option label a picker shows for a queue: its label, with the machine + * name beside it when the two differ, so an author can tell what is stored. + */ +export function queueCandidateLabel(c: QueueCandidate): string { + return c.label !== c.name ? `${c.label} (${c.name})` : c.name; +} diff --git a/ui/packages/fields/src/widgets/recipientKinds.ts b/ui/packages/fields/src/widgets/recipientKinds.ts index 45d40ecf9bc..caa9d9ad5d3 100644 --- a/ui/packages/fields/src/widgets/recipientKinds.ts +++ b/ui/packages/fields/src/widgets/recipientKinds.ts @@ -78,6 +78,34 @@ export const RECIPIENT_REGISTRY_KINDS: Readonly`. */ + type: string; + /** The object whose rows are the organization's own items. */ + object: string; + /** Both sources are referenced by machine name. */ + storeField: 'name'; + /** Candidate display-label keys, in preference order. */ + labelFields: string[]; + /** i18n key for the "choose one" placeholder (see {@link RecipientMapping}). */ + placeholderKey: string; +} + +/** + * The recipient kinds with two sources (ADR-0142 D7): a `queue` recipient + * names a queue a package declares (the `queue` registry type) or one the + * organization authored as a `sys_queue` row, and stores its machine name + * either way. The picker lists both through `mergeQueueCandidates`, so a + * declared queue and an organization's own queue are equally pickable. + */ +export const RECIPIENT_REGISTRY_AND_RECORD_KINDS: Readonly> = { + queue: { type: 'queue', object: 'sys_queue', storeField: 'name', labelFields: ['label', 'name'], placeholderKey: 'fields.recipient.selectQueue' }, +}; + /** * The RECORD-RELATIVE recipient kind (maintainer ruling objectstack#14103, * executor objectstack#15072). It is deliberately absent from diff --git a/ui/packages/fields/src/widgets/useFieldTranslation.ts b/ui/packages/fields/src/widgets/useFieldTranslation.ts index 985121f8330..b82dbfe11ab 100644 --- a/ui/packages/fields/src/widgets/useFieldTranslation.ts +++ b/ui/packages/fields/src/widgets/useFieldTranslation.ts @@ -103,6 +103,7 @@ const FIELD_DEFAULTS: Record = { 'fields.recipient.selectTeam': 'Select a team', 'fields.recipient.selectBusinessUnit': 'Select a business unit', 'fields.recipient.selectPosition': 'Select a position', + 'fields.recipient.selectQueue': 'Select a queue', 'fields.recipient.selectUnitAndSubordinates': 'Select a business unit', // objectui#7613 — the `field` recipient kind (maintainer ruling // objectstack#14103, executor objectstack#15072). It picks a user-valued diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 5abe698882a..3c96aca19b4 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -336,6 +336,7 @@ const ar = { selectTeam: "اختر فريقاً", selectBusinessUnit: "اختر وحدة عمل", selectPosition: "اختر منصباً", + selectQueue: "اختر قائمة انتظار", selectUnitAndSubordinates: "اختر وحدة عمل", selectField: "اختر حقل مستخدم", noUserFields: "لا توجد حقول مستخدم في هذا الكائن", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index d619b981248..368384d3302 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -318,6 +318,7 @@ const de = { selectTeam: "Team auswählen", selectBusinessUnit: "Geschäftseinheit auswählen", selectPosition: "Position auswählen", + selectQueue: "Warteschlange auswählen", selectUnitAndSubordinates: "Geschäftseinheit auswählen", selectField: "Benutzerfeld auswählen", noUserFields: "Dieses Objekt hat keine Benutzerfelder", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 2499dbb9f28..e3679422892 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -445,6 +445,7 @@ const en = { selectTeam: 'Select a team', selectBusinessUnit: 'Select a business unit', selectPosition: 'Select a position', + selectQueue: 'Select a queue', selectUnitAndSubordinates: 'Select a business unit', selectField: 'Select a user field', noUserFields: 'No user fields on this object', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 90f6ceb7cae..15ae1477569 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -326,6 +326,7 @@ const es = { selectTeam: "Seleccionar un equipo", selectBusinessUnit: "Seleccionar una unidad de negocio", selectPosition: "Seleccionar un puesto", + selectQueue: "Seleccionar una cola", selectUnitAndSubordinates: "Seleccionar una unidad de negocio", selectField: "Seleccionar un campo de usuario", noUserFields: "Este objeto no tiene campos de usuario", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index fde490cd556..dcf9a0740c2 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -321,6 +321,7 @@ const fr = { selectTeam: "Sélectionner une équipe", selectBusinessUnit: "Sélectionner une unité opérationnelle", selectPosition: "Sélectionner un poste", + selectQueue: "Sélectionner une file", selectUnitAndSubordinates: "Sélectionner une unité opérationnelle", selectField: "Sélectionner un champ utilisateur", noUserFields: "Cet objet n'a aucun champ utilisateur", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 7205735d81f..f55069b5ae7 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -318,6 +318,7 @@ const ja = { selectTeam: "チームを選択", selectBusinessUnit: "事業単位を選択", selectPosition: "役職を選択", + selectQueue: "キューを選択", selectUnitAndSubordinates: "事業単位を選択", selectField: "ユーザーフィールドを選択", noUserFields: "このオブジェクトにユーザーフィールドはありません", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 205ac6b097b..e09edb97bd4 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -318,6 +318,7 @@ const ko = { selectTeam: "팀 선택", selectBusinessUnit: "사업 단위 선택", selectPosition: "직위 선택", + selectQueue: "대기열 선택", selectUnitAndSubordinates: "사업 단위 선택", selectField: "사용자 필드 선택", noUserFields: "이 객체에는 사용자 필드가 없습니다", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index fb54e9d90a9..7881ae04be1 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -321,6 +321,7 @@ const pt = { selectTeam: "Selecionar uma equipe", selectBusinessUnit: "Selecionar uma unidade de negócio", selectPosition: "Selecionar um cargo", + selectQueue: "Selecionar uma fila", selectUnitAndSubordinates: "Selecionar uma unidade de negócio", selectField: "Selecionar um campo de usuário", noUserFields: "Este objeto não tem campos de usuário", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 40ebe272826..131b6bb3d0f 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -327,6 +327,7 @@ const ru = { selectTeam: "Выберите команду", selectBusinessUnit: "Выберите бизнес-подразделение", selectPosition: "Выберите должность", + selectQueue: "Выберите очередь", selectUnitAndSubordinates: "Выберите бизнес-подразделение", selectField: "Выберите поле пользователя", noUserFields: "В этом объекте нет полей пользователя", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index b061ae8fba0..c30b4984c55 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -324,6 +324,7 @@ const zh = { selectTeam: '请选择团队', selectBusinessUnit: '请选择业务单元', selectPosition: '请选择岗位', + selectQueue: '请选择队列', selectUnitAndSubordinates: '请选择业务单元', selectField: '请选择用户字段', noUserFields: '此对象没有用户字段', diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index b58f1a81c9e..4f2ec9e759d 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -1458,6 +1458,7 @@ const STUDIO_EN: Record = { // Flow reference picker — ReferenceCombobox / FlowReferenceField (objectui#10586) 'engine.inspector.reference.pickFromRecords': 'Pick from records', 'engine.inspector.reference.pickFromRegistry': 'Pick from declared items', + 'engine.inspector.reference.pickFromQueues': 'Pick from the queues', 'engine.inspector.reference.enterManually': 'Enter value manually', 'engine.inspector.reference.managerPlaceholder': 'Resolved automatically', 'engine.inspector.reference.managerHint': "Resolved at runtime from the submitter's manager — no value needed.", diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 53b9f9a326b..1d3a7390ef8 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1310,6 +1310,7 @@ const STUDIO_ZH: Record = { // Flow reference picker — ReferenceCombobox / FlowReferenceField (objectui#10586) 'engine.inspector.reference.pickFromRecords': '从记录中选择', 'engine.inspector.reference.pickFromRegistry': '从已声明的项中选择', + 'engine.inspector.reference.pickFromQueues': '从队列中选择', 'engine.inspector.reference.enterManually': '手动输入值', 'engine.inspector.reference.managerPlaceholder': '自动解析', 'engine.inspector.reference.managerHint': '运行时根据提交人的上级自动解析 —— 无需填写。', From d5ff93a3b287bbae7b2844ffc14afefc724b1e26 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:49:30 +0000 Subject: [PATCH 2/7] feat(ui): Claim on a record sitting in a queue, and an In-a-queue list filter (ADR-0142 D3/D4) Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz Co-Authored-By: Claude --- .../app-shell/src/views/RecordDetailView.tsx | 58 ++++++- ui/packages/i18n/src/locales/ar.ts | 6 + ui/packages/i18n/src/locales/de.ts | 6 + ui/packages/i18n/src/locales/en.ts | 6 + ui/packages/i18n/src/locales/es.ts | 6 + ui/packages/i18n/src/locales/fr.ts | 6 + ui/packages/i18n/src/locales/ja.ts | 6 + ui/packages/i18n/src/locales/ko.ts | 6 + ui/packages/i18n/src/locales/pt.ts | 6 + ui/packages/i18n/src/locales/ru.ts | 6 + ui/packages/i18n/src/locales/zh.ts | 6 + ui/packages/plugin-detail/src/index.tsx | 5 + ui/packages/plugin-detail/src/queueClaim.ts | 155 ++++++++++++++++++ ui/packages/plugin-list/src/ListView.tsx | 86 +++++++++- 14 files changed, 355 insertions(+), 9 deletions(-) create mode 100644 ui/packages/plugin-detail/src/queueClaim.ts diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index de4f1de4dbb..c933bddef51 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -10,12 +10,12 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useParams, useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom'; -import { activityRowToFeedItem, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, resolveTitleField, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; +import { activityRowToFeedItem, claimQueuedRecord, InlineEditSaveBar, RecordMetaFooter, RecordPageColumns, buildDefaultPageColumns, buildDefaultPageSchema, extractMentions, isRefusedFeedRead, recordPageColumnDefaults, recordSitsInQueue, resolveTitleField, useQueueClaimOffered, useRecordEditable, useRecordPageTier } from '@object-ui/plugin-detail'; import { Empty, EmptyTitle, EmptyDescription, Button, PageHeaderActions, PanelToggles, type PanelToggleItem } from '@object-ui/components'; import { useAuth, createAuthenticatedFetch } from '@object-ui/auth'; import { usePermissions } from '@object-ui/permissions'; import { useDisplayLocale } from '@object-ui/i18n'; -import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, isRecordPageOf, useMetadata, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError } from '@object-ui/react'; +import { ActionProvider, useObjectTranslation, useObjectLabel, useActionTextLocalizer, usePageAssignment, isRecordPageOf, useMetadata, RecordContextProvider, SchemaRenderer, DiscussionContextProvider, HighlightFieldsProvider, InlineEditProvider, resolveApprovalLock, useGlobalUndo, useDataInvalidation, notifyDataChanged, subscribeDataChanges, dataChangeMatches, useRowPredicate, classifyLoadError, extractWriteErrorMessage } from '@object-ui/react'; import { buildExpandFields, captureUpdateUndoData, recordDelete, resolveAffordance, resolveRecordIdParamSeed, userActionPredicates, withoutDeniedFields, type AffordanceGrantPrincipal, type SchemaLike } from '@object-ui/core'; import { toast } from 'sonner'; import { useRecordPresence, PresenceAvatars, isCommentAuthor } from '@object-ui/collaboration'; @@ -2040,6 +2040,45 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri !!objectDef?.name && !!pureRecordId && !deleteOffByObject, ); + // [objectstack ADR-0142 D4] Claim — a record that SITS IN an ownership queue + // (`owner_id` empty, `queue` set) offers its queue's members a Claim button. + // Whether THIS user may claim it is the server's answer alone: the explain + // engine's `queue_claim` verdict for a transfer, read by + // `useQueueClaimOffered`, which offers nothing until it says `admits`. The + // claim itself is `{ owner_id: }` through the normal update; a lost + // race comes back `409 CONCURRENT_UPDATE` and is said as "someone else + // claimed it", never pre-judged here. + const sittingInQueue = recordSitsInQueue(pageRecord); + const claimOffered = useQueueClaimOffered(objectDef?.name, pureRecordId, sittingInQueue !== null); + const [claiming, setClaiming] = useState(false); + const claimingRef = useRef(false); + const handleClaim = useCallback(async () => { + const objectName = objectDef?.name; + const self = perms.userId; + if (!dataSource || !objectName || !pureRecordId || !self || claimingRef.current) return; + claimingRef.current = true; + setClaiming(true); + try { + const result = await claimQueuedRecord(dataSource, objectName, pureRecordId, self); + if (result.outcome === 'claimed') { + toast.success(t('detail.claimed', { defaultValue: 'You own this record now.' })); + } else if (result.outcome === 'taken') { + toast.error(t('detail.claimTaken', { defaultValue: 'Someone else claimed this record first.' })); + } else { + toast.error( + extractWriteErrorMessage(result.error) + ?? t('detail.claimFailed', { defaultValue: 'The claim was not accepted.' }), + ); + } + // Every answer moved or confirmed who owns the row: re-read it in place, + // so the page shows the owner the server now holds (AGENTS.md #8). + notifyDataChanged({ objectName, recordId: pureRecordId }); + } finally { + claimingRef.current = false; + setClaiming(false); + } + }, [dataSource, objectDef?.name, pureRecordId, perms.userId, t]); + // ── Per-record `userActions` predicates — the header's FOURTH gate ──────── // // objectui#4213. `userActions.edit` / `.delete` reached this header in their @@ -3646,6 +3685,21 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri delete: objectAffordances.delete && recordDeleteAllowed && deleteVisible, }; const items: ActionDef[] = []; + // [objectstack ADR-0142 D4] Claim, ahead of Edit: on a record sitting in a + // queue it is the gesture a queue member came for. Offered only on the + // server's `queue_claim` admission (see `useQueueClaimOffered` above). + if (sittingInQueue !== null && claimOffered && perms.userId) { + items.push({ + name: 'sys_claim', + label: t('detail.claim', { defaultValue: 'Claim' }), + icon: 'hand', + type: 'script', + locations: ['record_header'], + order: 95, + disabled: claiming, + onClick: handleClaim, + } as any); + } if (affordances.edit) { // Single primary Edit CTA → opens the full record form. Inline editing // is no longer a standalone header button; it is entered by diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 3c96aca19b4..bf525a5ee62 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -893,6 +893,8 @@ const ar = { viewAllCount_other: "عرض {{count}} سجل", tabs: "علامات التبويب", allRecords: "جميع السجلات", + sittingInQueue: "في قائمة انتظار", + sittingInQueueHint: "عرض السجلات التي تنتظر في قائمة انتظار ليستلمها أحد فقط", userFilters: { allRecords: "جميع السجلات", }, @@ -1432,6 +1434,10 @@ const ar = { commentEditFailed: "لم يُحفظ تعديلك. يحتفظ التعليق بنصه السابق.", commentDeleteFailed: "لم يُحذف التعليق. يُرجى المحاولة مرة أخرى.", linkCopyFailed: "فشل نسخ الرابط", + claim: "استلام", + claimed: "أصبحت مالك هذا السجل الآن.", + claimTaken: "استلم شخص آخر هذا السجل قبلك.", + claimFailed: "لم يُقبل الاستلام.", cancel: "إلغاء", cancelEdit: "تجاهل التغييرات", sectionMoreDetails: "مزيد من التفاصيل", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 368384d3302..322ba536c00 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -792,6 +792,8 @@ const de = { viewAllCount_other: "Alle {{count}} Datensätze anzeigen", tabs: "Tabs", allRecords: "Alle Datensätze", + sittingInQueue: "In einer Warteschlange", + sittingInQueueHint: "Nur Datensätze anzeigen, die in einer Warteschlange auf Übernahme warten", userFilters: { allRecords: "Alle Datensätze", }, @@ -1278,6 +1280,10 @@ const de = { commentEditFailed: "Ihre Änderung wurde nicht gespeichert. Der Kommentar behält seinen bisherigen Text.", commentDeleteFailed: "Der Kommentar wurde nicht gelöscht. Bitte versuchen Sie es erneut.", linkCopyFailed: "Link konnte nicht kopiert werden", + claim: "Übernehmen", + claimed: "Dieser Datensatz gehört jetzt Ihnen.", + claimTaken: "Jemand anderes hat diesen Datensatz zuerst übernommen.", + claimFailed: "Die Übernahme wurde nicht angenommen.", cancel: "Abbrechen", cancelEdit: "Änderungen verwerfen", sectionMoreDetails: "Weitere Details", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index e3679422892..1d31a6f60f3 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1002,6 +1002,8 @@ const en = { viewAllCount_other: 'View all {{count}} records', tabs: 'Tabs', allRecords: 'All Records', + sittingInQueue: 'In a queue', + sittingInQueueHint: 'Show only records waiting in a queue for someone to claim them', // objectui#13405 — the synthetic trailing tab a `showAllRecords` user-filter bar adds. userFilters: { allRecords: 'All records', @@ -1409,6 +1411,10 @@ const en = { commentEditFailed: 'Your edit was not saved. The comment keeps its previous text.', commentDeleteFailed: 'The comment was not deleted. Please try again.', linkCopyFailed: 'Failed to copy link', + claim: 'Claim', + claimed: 'You own this record now.', + claimTaken: 'Someone else claimed this record first.', + claimFailed: 'The claim was not accepted.', cancel: 'Cancel', cancelEdit: 'Discard changes', sectionMoreDetails: 'More details', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 15ae1477569..c7fbb9559d1 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -820,6 +820,8 @@ const es = { viewAllCount_other: "Ver los {{count}} registros", tabs: "Pestañas", allRecords: "Todos los registros", + sittingInQueue: "En una cola", + sittingInQueueHint: "Mostrar solo los registros que esperan en una cola a que alguien los reclame", userFilters: { allRecords: "Todos los registros", }, @@ -1318,6 +1320,10 @@ const es = { commentEditFailed: "Tu edición no se ha guardado. El comentario conserva su texto anterior.", commentDeleteFailed: "El comentario no se ha eliminado. Inténtalo de nuevo.", linkCopyFailed: "No se pudo copiar el enlace", + claim: "Reclamar", + claimed: "Ahora este registro es tuyo.", + claimTaken: "Otra persona reclamó este registro primero.", + claimFailed: "No se aceptó la reclamación.", cancel: "Cancelar", cancelEdit: "Descartar cambios", sectionMoreDetails: "Más detalles", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index dcf9a0740c2..9a4810ddc0d 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -815,6 +815,8 @@ const fr = { viewAllCount_other: "Voir les {{count}} enregistrements", tabs: "Onglets", allRecords: "Tous les enregistrements", + sittingInQueue: "Dans une file", + sittingInQueueHint: "Afficher uniquement les enregistrements en attente de prise en charge dans une file", userFilters: { allRecords: "Tous les enregistrements", }, @@ -1315,6 +1317,10 @@ const fr = { commentEditFailed: "Votre modification n'a pas été enregistrée. Le commentaire conserve son texte précédent.", commentDeleteFailed: "Le commentaire n'a pas été supprimé. Veuillez réessayer.", linkCopyFailed: "Impossible de copier le lien", + claim: "Prendre en charge", + claimed: "Cet enregistrement vous appartient désormais.", + claimTaken: "Quelqu'un d'autre a pris cet enregistrement en premier.", + claimFailed: "La prise en charge n'a pas été acceptée.", cancel: "Annuler", cancelEdit: "Abandonner les modifications", sectionMoreDetails: "Plus de détails", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index f55069b5ae7..8a2b30c2acf 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -792,6 +792,8 @@ const ja = { viewAllCount_other: "すべて表示({{count}} 件)", tabs: "タブ", allRecords: "すべてのレコード", + sittingInQueue: "キュー内", + sittingInQueueHint: "キューで担当者を待っているレコードのみ表示", userFilters: { allRecords: "すべてのレコード", }, @@ -1294,6 +1296,10 @@ const ja = { commentEditFailed: "編集内容は保存されませんでした。コメントは以前の内容のままです。", commentDeleteFailed: "コメントは削除されませんでした。もう一度お試しください。", linkCopyFailed: "リンクのコピーに失敗しました", + claim: "担当する", + claimed: "このレコードの担当者になりました。", + claimTaken: "他のユーザーが先にこのレコードを担当しました。", + claimFailed: "担当の申請は受け付けられませんでした。", cancel: "キャンセル", cancelEdit: "変更を破棄", sectionMoreDetails: "詳細情報", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index e09edb97bd4..69a46837c5f 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -792,6 +792,8 @@ const ko = { viewAllCount_other: "전체 {{count}}개 보기", tabs: "탭", allRecords: "전체 레코드", + sittingInQueue: "대기열에 있음", + sittingInQueueHint: "대기열에서 담당자를 기다리는 레코드만 표시", userFilters: { allRecords: "전체 레코드", }, @@ -1278,6 +1280,10 @@ const ko = { commentEditFailed: "수정 내용이 저장되지 않았습니다. 댓글은 이전 내용을 유지합니다.", commentDeleteFailed: "댓글이 삭제되지 않았습니다. 다시 시도해 주세요.", linkCopyFailed: "링크 복사 실패", + claim: "담당하기", + claimed: "이제 이 레코드의 담당자입니다.", + claimTaken: "다른 사용자가 먼저 이 레코드를 담당했습니다.", + claimFailed: "담당 요청이 수락되지 않았습니다.", cancel: "취소", cancelEdit: "변경 사항 취소", sectionMoreDetails: "추가 세부 정보", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 7881ae04be1..09143a30963 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -815,6 +815,8 @@ const pt = { viewAllCount_other: "Ver os {{count}} registros", tabs: "Abas", allRecords: "Todos os registros", + sittingInQueue: "Em uma fila", + sittingInQueueHint: "Mostrar apenas registros aguardando em uma fila para alguém assumir", userFilters: { allRecords: "Todos os registros", }, @@ -1315,6 +1317,10 @@ const pt = { commentEditFailed: "Sua edição não foi salva. O comentário mantém o texto anterior.", commentDeleteFailed: "O comentário não foi excluído. Tente novamente.", linkCopyFailed: "Falha ao copiar o link", + claim: "Assumir", + claimed: "Este registro agora é seu.", + claimTaken: "Outra pessoa assumiu este registro primeiro.", + claimFailed: "A solicitação não foi aceita.", cancel: "Cancelar", cancelEdit: "Descartar alterações", sectionMoreDetails: "Mais detalhes", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 131b6bb3d0f..cf15344b5b4 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -844,6 +844,8 @@ const ru = { viewAllCount_other: "Показать все {{count}} записи", tabs: "Вкладки", allRecords: "Все записи", + sittingInQueue: "В очереди", + sittingInQueueHint: "Показать только записи, ожидающие в очереди, пока их кто-нибудь возьмёт", userFilters: { allRecords: "Все записи", }, @@ -1375,6 +1377,10 @@ const ru = { commentEditFailed: "Изменения не сохранены. Комментарий сохраняет прежний текст.", commentDeleteFailed: "Комментарий не удалён. Попробуйте ещё раз.", linkCopyFailed: "Не удалось скопировать ссылку", + claim: "Взять в работу", + claimed: "Теперь эта запись ваша.", + claimTaken: "Кто-то другой взял эту запись раньше вас.", + claimFailed: "Запрос не был принят.", cancel: "Отмена", cancelEdit: "Отменить изменения", sectionMoreDetails: "Подробнее", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index c30b4984c55..c524fd7f2bd 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -767,6 +767,8 @@ const zh = { viewAllCount_other: '查看全部 {{count}} 条', tabs: '标签页', allRecords: '全部记录', + sittingInQueue: '在队列中', + sittingInQueueHint: '只显示在队列中等待认领的记录', userFilters: { allRecords: '全部记录', }, @@ -1160,6 +1162,10 @@ const zh = { commentEditFailed: '您的修改未保存,评论保留原有内容。', commentDeleteFailed: '评论未删除,请重试。', linkCopyFailed: '复制链接失败', + claim: '认领', + claimed: '这条记录现在归你了。', + claimTaken: '已有其他人先认领了这条记录。', + claimFailed: '认领未被接受。', cancel: '取消', cancelEdit: '放弃更改', sectionMoreDetails: '更多详情', diff --git a/ui/packages/plugin-detail/src/index.tsx b/ui/packages/plugin-detail/src/index.tsx index 21796eb75a8..2518db49050 100644 --- a/ui/packages/plugin-detail/src/index.tsx +++ b/ui/packages/plugin-detail/src/index.tsx @@ -129,6 +129,11 @@ export type { // (objectui#13453); tests import it from `useRecordEditable` directly. export { useRecordEditable } from './useRecordEditable'; export type { RecordOperation } from './useRecordEditable'; +// The Claim affordance on a record sitting in an ownership queue (objectstack +// ADR-0142 D4): the server's `queue_claim` answer decides the button, the +// claim is `{ owner_id: }` through the normal update. +export { recordSitsInQueue, queueClaimAdmitted, useQueueClaimOffered, claimQueuedRecord } from './queueClaim'; +export type { QueueClaimOutcome, ClaimWriter } from './queueClaim'; export { SectionGroup } from './SectionGroup'; export { HeaderHighlight } from './HeaderHighlight'; export { InlineFieldInput, extractLookupId, TEXTUAL_REF_FALLBACK_TYPES } from './InlineFieldInput'; diff --git a/ui/packages/plugin-detail/src/queueClaim.ts b/ui/packages/plugin-detail/src/queueClaim.ts new file mode 100644 index 00000000000..cd2405130f9 --- /dev/null +++ b/ui/packages/plugin-detail/src/queueClaim.ts @@ -0,0 +1,155 @@ +/** + * 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. + */ + +/** + * The Claim affordance on a record that sits in an ownership queue + * (objectstack ADR-0142 D3/D4). + * + * A record SITS IN queue Q when its `owner_id` is empty and its system-managed + * `queue` column names Q. A claim is an ordinary update of `owner_id` to the + * caller's own id: the server's ownership gate admits it when the record is + * unowned and the sharing model positively admits the caller (a member of the + * queue is the common case), writes it on the condition that the owner is + * still empty, and answers the loser of a race `409 CONCURRENT_UPDATE` ("the + * record was taken"). + * + * The server is the only judge. This module holds two reads and one write: + * + * - {@link recordSitsInQueue} reads the record's own two columns to decide + * whether there is anything to ASK about. It judges no right: a record that + * sits in no queue has no Claim button, whoever looks at it. + * - {@link useQueueClaimOffered} asks the explain engine whether a claim of + * THIS record by THIS user is admitted (`POST /api/v1/security/explain`, + * `operation: 'transfer'`) and reads the `queue_claim` attribution the + * engine reports from the same sharing verdict the gate reads. The button + * renders only on `admits`. ⛔ No membership test, no permission arithmetic + * here: a second client-side judge is exactly what the server's answer + * replaces. + * - {@link claimQueuedRecord} sends `{ owner_id: }` through the normal + * update path and reports the server's answer as it came: claimed, taken by + * someone else (the 409), or refused for another reason. + * + * Unlike `useRecordEditable`, the probe FAILS CLOSED: Edit is an affordance the + * record already has and a hint must never hide it; Claim exists only where + * the server says it applies, so no answer means no button. + */ +import * as React from 'react'; +import { SchemaRendererContext, useDataInvalidation } from '@object-ui/react'; +import { usePermissions } from '@object-ui/permissions'; +import { isConcurrentUpdateError } from './ConcurrentUpdateDialog'; + +/** + * The queue a record sits in, or `null`: `owner_id` empty and `queue` a + * non-empty name (ADR-0142 D3). With an owner, `queue` is only the record's + * home queue and nothing sits anywhere. + */ +export function recordSitsInQueue(record: unknown): string | null { + if (!record || typeof record !== 'object') return null; + const r = record as { owner_id?: unknown; queue?: unknown }; + const owner = r.owner_id; + const unowned = owner == null || owner === ''; + return unowned && typeof r.queue === 'string' && r.queue !== '' ? r.queue : null; +} + +/** + * Does an explain decision for `operation: 'transfer'` admit the principal's + * CLAIM of the record? True exactly when a layer's record attribution carries + * the `queue_claim` rule with `effect: 'admits'` — the engine's report of the + * sharing verdict the ownership gate reads for a claim. Anything else, + * including a malformed answer, is `false`. + */ +export function queueClaimAdmitted(decision: unknown): boolean { + const layers = (decision as { layers?: unknown } | null | undefined)?.layers; + if (!Array.isArray(layers)) return false; + return layers.some((layer) => { + const rules = (layer as { record?: { rules?: unknown } } | null | undefined)?.record?.rules; + return Array.isArray(rules) && rules.some((rule) => { + const r = rule as { kind?: unknown; effect?: unknown } | null | undefined; + return r?.kind === 'queue_claim' && r.effect === 'admits'; + }); + }); +} + +/** + * Whether to offer Claim on this record: asked of the server only for a record + * that sits in a queue, with a known acting user, and answered `true` only + * when the server admits the claim. Re-asked when the record changes on the + * data-invalidation bus (a claim by this tab lands there) and when the acting + * user changes. A failed or unanswered probe is `false`. + */ +export function useQueueClaimOffered( + objectName: string | undefined, + recordId: string | undefined, + sitting: boolean, +): boolean { + const principal = usePermissions().userId ?? null; + const invalidationNonce = useDataInvalidation(objectName, recordId); + const apiFetch = React.useContext(SchemaRendererContext)?.apiFetch; + const [offered, setOffered] = React.useState(false); + + React.useEffect(() => { + setOffered(false); + if (!sitting || !objectName || !recordId || !principal) return; + let cancelled = false; + (async () => { + try { + const doFetch = apiFetch ?? fetch; + const res = await doFetch('/api/v1/security/explain', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ object: objectName, operation: 'transfer', recordId }), + }); + if (!res.ok) return; + const decision = await res.json(); + if (!cancelled) setOffered(queueClaimAdmitted(decision)); + } catch { + // No answer is not an admission: the button stays away. + } + })(); + return () => { + cancelled = true; + }; + }, [objectName, recordId, sitting, principal, apiFetch, invalidationNonce]); + + return offered; +} + +/** The server's answer to a claim, as the Claim affordance reports it. */ +export type QueueClaimOutcome = + | { readonly outcome: 'claimed' } + /** `409 CONCURRENT_UPDATE`: another user took the record first. */ + | { readonly outcome: 'taken' } + /** Any other refusal, carried as thrown so the caller can say what it was. */ + | { readonly outcome: 'refused'; readonly error: unknown }; + +/** The one write a claim is: `owner_id` set to the caller, through `update`. */ +export interface ClaimWriter { + update(objectName: string, recordId: string, data: Record): Promise; +} + +/** + * Claim a record: `{ owner_id: }` through the normal update path, + * nothing else in the payload — the queue column stays as it is, because a + * claim leaves the home queue unchanged (ADR-0142 D4). The server decides; + * this reports which answer it gave. + */ +export async function claimQueuedRecord( + writer: ClaimWriter, + objectName: string, + recordId: string, + userId: string, +): Promise { + try { + await writer.update(objectName, recordId, { owner_id: userId }); + return { outcome: 'claimed' }; + } catch (error) { + if (isConcurrentUpdateError(error)) return { outcome: 'taken' }; + return { outcome: 'refused', error }; + } +} diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index e56362db506..9320dd998a1 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -9,7 +9,7 @@ import * as React from 'react'; import { cn, Button, Input, Popover, PopoverContent, PopoverTrigger, FilterBuilder, SortBuilder, NavigationOverlay, GroupingEditor, RefreshIndicator, DataErrorState, resolveIcon, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@object-ui/components'; import type { SortItem } from '@object-ui/components'; -import { Search, Funnel, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Download, Rows4, Rows3, Rows2, Share2, Lock, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, Info } from 'lucide-react'; +import { Search, Funnel, ArrowUpDown, X, EyeOff, Pencil, Group, Paintbrush, Download, Rows4, Rows3, Rows2, Share2, Lock, Printer, Plus, Trash2, CheckSquare, AlertTriangle, ShieldAlert, RotateCw, Loader2, Info, Inbox } from 'lucide-react'; import type { FilterBuilderRowGroup } from '@object-ui/components'; import { VALUELESS_FILTER_BUILDER_OPERATORS, isFilterValueComplete } from '@object-ui/components'; import { ViewSwitcherDropdown } from './ViewSwitcher'; @@ -842,6 +842,7 @@ export function buildEffectiveFilter( baseFilter: unknown, currentFilters: FilterBuilderRowGroup, userFilterConditions: any[], + queueSittingConditions: ReadonlyArray = NO_QUEUE_SITTING_CONDITIONS, ): any[] | Record | undefined { const userFilter = convertFilterGroupToAST(currentFilters); return mergeFilterNodes( @@ -850,9 +851,47 @@ export function buildEffectiveFilter( // Normalize the per-field conditions (expands `in` into an `or` of `=`), // each of which is already its own node. ...normalizeFilters(userFilterConditions), + // The "In a queue" toggle (objectstack ADR-0142 D3), AND-ed like the rest. + ...queueSittingConditions.map((c) => [...c]), ); } +/** One condition of the "In a queue" filter, in the filter AST's triple form. */ +export type QueueSittingCondition = readonly [field: string, operator: string, value: boolean]; + +/** + * The "In a queue" filter (objectstack ADR-0142 D3): the records that SIT IN + * an ownership queue — no owner, and a `queue` named. That is the platform's + * own definition of a parked record, so the filter states exactly it and + * nothing about who may see or claim the rows: what comes back is whatever the + * server's read filter lets this user read (a queue's members read what sits + * in their queues). Module constants, so the fetch effect sees one identity + * per state (AGENTS.md #10). + */ +export const QUEUE_SITTING_CONDITIONS: ReadonlyArray = [ + ['owner_id', 'is_null', true], + ['queue', 'is_not_null', true], +]; +const NO_QUEUE_SITTING_CONDITIONS: ReadonlyArray = []; + +/** + * Does the server say this object has ownership queues? True when the served + * object definition carries the platform-injected `queue` column (`system: + * true`, ADR-0142 D3), which the server provisions on exactly the objects whose + * records can sit in a queue (the `ownership: 'user'` tier) and refuses an + * author's own `queue` field on. The filter is offered only there: anywhere + * else it would name a column the object does not have. + */ +export function objectHasOwnershipQueue(objectDef: unknown): boolean { + const raw = (objectDef as { fields?: unknown } | null | undefined)?.fields; + const queue = Array.isArray(raw) + ? raw.find((f) => (f as { name?: unknown } | null)?.name === 'queue') + : raw && typeof raw === 'object' + ? (raw as Record).queue + : undefined; + return !!queue && typeof queue === 'object' && (queue as { system?: unknown }).system === true; +} + /** * What `ListView` applies in place of a held user filter it withholds * (`appliedFilters` / `appliedUserFilterConditions`, objectui#10512). Module @@ -1103,6 +1142,8 @@ export const LIST_DEFAULT_TRANSLATIONS: Record = { 'list.addRecord': 'Add record', 'list.tabs': 'Tabs', 'list.allRecords': 'All Records', + 'list.sittingInQueue': 'In a queue', + 'list.sittingInQueueHint': 'Show only records waiting in a queue for someone to claim them', 'list.share': 'Share', 'list.sharingPersonal': 'Personal view', 'list.sharingCollaborative': 'Shared view', @@ -2056,6 +2097,17 @@ export const ListView = React.forwardRef(({ ? WITHHELD_USER_FILTER_CONDITIONS : userFilterConditions; + // The "In a queue" toggle (objectstack ADR-0142 D3). Offered only on an + // object the server serves with its injected `queue` column; withheld with + // the user filters on a dataset-bound chart. Ephemeral like the toolbar's + // other unpersisted toggles: it narrows what is on screen, nothing else. + const [sittingInQueueOnly, setSittingInQueueOnly] = React.useState(false); + const queueFilterOffered = objectHasOwnershipQueue(objectDef); + const appliedQueueSittingConditions = + queueFilterOffered && sittingInQueueOnly && !datasetChartOnScreen + ? QUEUE_SITTING_CONDITIONS + : NO_QUEUE_SITTING_CONDITIONS; + // User filters render ONLY when explicitly configured (ADR-0047 §data // mode): saved list views already act as the preset switcher, so an // unconfigured view keeps a clean toolbar instead of growing auto-derived @@ -2955,7 +3007,7 @@ export const ListView = React.forwardRef(({ try { // Construct filter — shared with the export path so the file a user // downloads is built from the same three sources as the rows on screen. - const finalFilter = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); + const finalFilter = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions); // Convert sort to query format // Use array format to ensure order is preserved (Object keys are not guaranteed ordered) @@ -3610,7 +3662,7 @@ export const ListView = React.forwardRef(({ // silently un-suppresses nothing, because the finding it was suppressing // simply moves elsewhere. Add prose ABOVE this point, never below it. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on + }, [schema.objectName, schema.data, dataSource, authoredFilter, effectivePageSize, currentSort, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, refreshKey, searchTerm, schema.searchableFields, schema.columns, (schema as any).kanban, (schema as any).calendar, (schema as any).gallery, (schema as any).timeline, (schema as any).gantt, schema.map, (schema as any).options, objectDef?.fields, objectDefLoaded, schema.refreshTrigger, perms, fetchSkip, groupingConfig, predicateProjectionKey, invalidationNonce, groupingNeedsHeaderQuery, calendarWindowKey, rowsCarryConcurrencyToken]); // Re-fetch on filter/sort/search/refreshTrigger/perms/window change; `rowsCarryConcurrencyToken` (objectui#12292) re-reads the rows with their token when inline edit turns on // Any change to the result-defining inputs (object, filters, sort, search, // grouping, page size) invalidates the current page number — snap back to @@ -3621,7 +3673,7 @@ export const ListView = React.forwardRef(({ // the signature, so turning the page never triggers a reset. const pageResetSignature = JSON.stringify([ schema.objectName, authoredFilter, effectivePageSize, currentSort, - appliedFilters, appliedUserFilterConditions, searchTerm, currentView, groupingConfig, + appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, searchTerm, currentView, groupingConfig, ]); const prevPageResetSignature = React.useRef(pageResetSignature); React.useEffect(() => { @@ -4020,7 +4072,7 @@ export const ListView = React.forwardRef(({ let selfQueryFilter: unknown = authoredFilter; if (currentView === 'gantt' || currentView === 'tree' || currentView === 'chart' || gridOwnsGroupedFetch || kanbanLanesPaged) { try { - const value = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); + const value = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions); const key = JSON.stringify(value ?? null); const cached = selfQueryFilterRef.current; if (cached && cached.key === key) { @@ -5136,7 +5188,7 @@ export const ListView = React.forwardRef(({ // file, no message. Here it lands in the catch below, which names it // in the toolbar the way the data fetch's own `try` names it in the // load-error panel — and nothing is exported unfiltered. - const finalFilter = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions); + const finalFilter = buildEffectiveFilter(authoredFilter, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions); const blob = await dataSource!.exportDownload!(schema.objectName!, { format: format as 'csv' | 'xlsx' | 'json', fields: fields.length ? fields : undefined, @@ -5227,7 +5279,7 @@ export const ListView = React.forwardRef(({ setShowExport(false); // `searchTerm` / `searchableFields` belong here: the export now narrows by // the active search, so a stale closure would export the wrong row set. - }, [data, effectiveFields, resolvedExportOptions, schema.objectName, authoredFilter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, currentSort, searchTerm, objectDef, resolveObjectLabel]); + }, [data, effectiveFields, resolvedExportOptions, schema.objectName, authoredFilter, schema.searchableFields, exportPermitted, dataSource, appliedFilters, appliedUserFilterConditions, appliedQueueSittingConditions, currentSort, searchTerm, objectDef, resolveObjectLabel]); // All available fields for hide/show (with i18n) // @@ -5587,6 +5639,25 @@ export const ListView = React.forwardRef(({ /> )} + {/* "In a queue" (objectstack ADR-0142 D3): the records sitting in an + ownership queue, waiting to be claimed. Rendered only where the + served object carries the injected `queue` column; a pressed + toggle, so its state is announced and printed like a chip. */} + {queueFilterOffered && !datasetChartOnScreen && ( + + )} {/* `data-print-hide`: the tool cluster is pure interaction (print, @@ -6447,6 +6518,7 @@ export const ListView = React.forwardRef(({ hasUserQuery={ !!(searchTerm && searchTerm.trim()) || (Array.isArray(appliedUserFilterConditions) && appliedUserFilterConditions.length > 0) || + appliedQueueSittingConditions.length > 0 || (Array.isArray(appliedFilters?.conditions) && appliedFilters.conditions.length > 0) } onAddRecord={toolbarFlags.showAddRecord ? () => props.onAddRecord?.() : undefined} From 00490a5320f657e8ec7a73e8b5758a7c1268c542 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:53:52 +0000 Subject: [PATCH 3/7] test(ui): pin the queue pickers, the Claim probe and write, and the In-a-queue filter (ADR-0142) json-schema-to-fields now keeps a whole registry-and-data binding, so a published two-source queue binding reaches the approver picker. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz --- ...wReferenceField.queuePicker-25769.test.tsx | 211 ++++++++++++++++++ .../inspectors/json-schema-to-fields.ts | 12 + .../RecipientPickerField.queue-25769.test.tsx | 126 +++++++++++ .../src/queueClaim-25769.test.tsx | 175 +++++++++++++++ ...ListView.queueSittingFilter-25769.test.tsx | 126 +++++++++++ 5 files changed, 650 insertions(+) create mode 100644 ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.queuePicker-25769.test.tsx create mode 100644 ui/packages/fields/src/widgets/RecipientPickerField.queue-25769.test.tsx create mode 100644 ui/packages/plugin-detail/src/queueClaim-25769.test.tsx create mode 100644 ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.queuePicker-25769.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.queuePicker-25769.test.tsx new file mode 100644 index 00000000000..8663a5d3720 --- /dev/null +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/FlowReferenceField.queuePicker-25769.test.tsx @@ -0,0 +1,211 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectstack ADR-0142 D6 — the approver Value picker for a `queue` approver + * lists BOTH of a queue's sources: the queues a package declares + * (`GET /api/v1/meta/queue`, through the metadata client) and the + * organization's own `sys_queue` rows (through the data adapter). The server + * publishes the binding as `{ source: 'registry-and-data', type, object, + * valueField }`; a choice commits the queue's machine name. + * + * Load-bearing behaviours, one pin each: + * 1. a published two-source binding survives `json-schema-to-fields` whole, + * and a half one is dropped; + * 2. `twoSourceFor` follows the published binding, and the bundled spec only + * when nothing is published; + * 3. the render condition and the two requests: with an adapter, both lists + * are read and merged; a choice commits the NAME; + * 4. never trap the author: no adapter or an empty list leaves the text box, + * a stored name neither source lists is shown flagged, and a server that + * publishes `queue` as anything else keeps the plain text cell. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach, beforeAll } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const state = vi.hoisted(() => { + const metaList = vi.fn(async (_type: string) => [] as unknown[]); + return { + adapter: null as unknown, + metaList, + // STABLE identity, like the real memoized client. + metadataClient: { list: metaList }, + }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useAdapter: () => state.adapter, + // @object-ui/components wires this at module scope (related-count-store). + subscribeDataChanges: () => () => {}, +})); +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + LookupField: () =>
, +})); +vi.mock('../useMetadata', () => ({ + useMetadataClient: () => state.metadataClient, +})); +vi.mock('../previews/useObjectFields', () => ({ + useObjectFields: () => ({ fields: [] }), +})); + +import { ReferenceCombobox, twoSourceFor, KIND_TO_TWO_SOURCE } from './FlowReferenceField'; +import { jsonSchemaToFlowFields } from './json-schema-to-fields'; +import type { RefValueSource } from './flow-node-config'; + +beforeAll(() => { + // Radix Select probes pointer-capture APIs the test DOM lacks. + for (const m of ['hasPointerCapture', 'setPointerCapture', 'releasePointerCapture'] as const) { + if (!Element.prototype[m]) { + // @ts-expect-error test shim + Element.prototype[m] = m === 'hasPointerCapture' ? () => false : () => {}; + } + } +}); + +afterEach(() => { + cleanup(); + state.adapter = null; + state.metaList.mockReset(); + state.metaList.mockImplementation(async () => []); +}); + +/** The two-source member, as a server carrying ADR-0142 D6 publishes `queue`. */ +const TWO_SOURCE: RefValueSource = { source: 'registry-and-data', type: 'queue', object: 'sys_queue', valueField: 'name' }; + +const DECLARED = [{ name: 'support_intake', label: 'Support Intake' }]; +const AUTHORED = [{ id: 'q_1', name: 'shanghai_desk', label: 'Shanghai Desk' }]; + +function makeAdapter(rows: unknown[] = AUTHORED) { + return { + find: vi.fn(async () => ({ data: rows })), + findOne: vi.fn(async () => null), + getObjectSchema: vi.fn(async () => undefined), + }; +} + +function renderQueue( + source: RefValueSource | undefined, + props: Partial> = {}, +) { + return render( + , + ); +} + +describe('json-schema-to-fields carries the two-source binding (ADR-0142 D6)', () => { + function sourcesFor(sources: Record) { + const fields = jsonSchemaToFlowFields({ + type: 'object', + properties: { + approvers: { + type: 'array', + items: { + type: 'object', + properties: { + value: { type: 'string', xRef: { kindFrom: 'type', map: { queue: 'queue' }, sources } }, + }, + }, + }, + }, + })!; + return fields.find((f) => f.id === 'approvers')!.columns!.find((c) => c.key === 'value')!.ref!.sources; + } + + it('keeps a whole two-source binding', () => { + expect(sourcesFor({ queue: TWO_SOURCE })).toEqual({ queue: TWO_SOURCE }); + }); + + it('drops a binding missing a half — it could only offer half the queues', () => { + expect(sourcesFor({ queue: { source: 'registry-and-data', type: 'queue' } })).toBeUndefined(); + }); +}); + +describe('twoSourceFor — the published binding decides (ADR-0142 D6)', () => { + it('answers the published two-source binding, and nothing for another member', () => { + expect(twoSourceFor({ kind: 'queue', source: TWO_SOURCE })).toEqual(TWO_SOURCE); + expect(twoSourceFor({ kind: 'queue', source: { source: 'unsupported' } })).toBeUndefined(); + expect(twoSourceFor({ kind: 'position', source: { source: 'registry', type: 'position' } })).toBeUndefined(); + }); + + it('falls back to the bundled spec only when nothing is published', () => { + expect(KIND_TO_TWO_SOURCE.queue).toMatchObject({ type: 'queue', object: 'sys_queue', valueField: 'name' }); + expect(twoSourceFor({ kind: 'queue' })).toEqual(KIND_TO_TWO_SOURCE.queue); + expect(twoSourceFor(undefined)).toBeUndefined(); + }); +}); + +describe('ReferenceCombobox — a queue approver picks from both sources (ADR-0142 D6)', () => { + it('reads the declared queues and the sys_queue rows, and commits the chosen NAME', async () => { + const adapter = makeAdapter(); + state.adapter = adapter; + state.metaList.mockImplementation(async () => DECLARED); + const onSelect = vi.fn(); + renderQueue(TWO_SOURCE, { onSelect }); + + const cell = await screen.findByTestId('two-source-pick'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + expect(state.metaList).toHaveBeenCalledWith('queue'); + expect(adapter.find).toHaveBeenCalledWith('sys_queue', { $top: 500, $orderby: { name: 'asc' } }); + expect(screen.queryByTestId('record-lookup')).toBeNull(); + + await userEvent.click(screen.getByRole('combobox')); + expect(await screen.findByRole('option', { name: 'Support Intake (support_intake)' })).toBeInTheDocument(); + await userEvent.click(await screen.findByRole('option', { name: 'Shanghai Desk (shanghai_desk)' })); + expect(onSelect).toHaveBeenCalledWith('shanghai_desk'); + }); + + it('shows a stored name neither source lists, flagged, instead of blanking it', async () => { + state.adapter = makeAdapter(); + state.metaList.mockImplementation(async () => DECLARED); + renderQueue(TWO_SOURCE, { value: 'retired_desk' }); + + const cell = await screen.findByTestId('two-source-pick'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + expect(screen.getByRole('combobox').textContent).toContain('retired_desk'); + expect(screen.getByRole('combobox').textContent).toContain('(not found)'); + }); + + it('switches to manual entry and back', async () => { + state.adapter = makeAdapter(); + state.metaList.mockImplementation(async () => DECLARED); + const onCommit = vi.fn(); + renderQueue(TWO_SOURCE, { onCommit }); + + const cell = await screen.findByTestId('two-source-pick'); + await vi.waitFor(() => expect(cell.getAttribute('data-mode')).toBe('select')); + fireEvent.click(screen.getByTitle('Enter value manually')); + expect(screen.getByTestId('two-source-pick').getAttribute('data-mode')).toBe('text'); + fireEvent.change(screen.getByRole('textbox'), { target: { value: 'typed_desk' } }); + expect(onCommit).toHaveBeenCalledWith('typed_desk'); + fireEvent.click(screen.getByTitle('Pick from the queues')); + expect(screen.getByTestId('two-source-pick').getAttribute('data-mode')).toBe('select'); + }); + + it('with no adapter reads nothing and keeps the text box — one source alone is half a list', async () => { + state.metaList.mockImplementation(async () => DECLARED); + const onCommit = vi.fn(); + renderQueue(TWO_SOURCE, { value: 'support_intake', onCommit }); + + expect(screen.getByTestId('two-source-pick').getAttribute('data-mode')).toBe('text'); + expect(state.metaList).not.toHaveBeenCalled(); + fireEvent.change(screen.getByDisplayValue('support_intake'), { target: { value: 'other_desk' } }); + expect(onCommit).toHaveBeenCalledWith('other_desk'); + }); + + it('keeps the plain text cell for a server that publishes queue as anything else', () => { + state.adapter = makeAdapter(); + renderQueue({ source: 'unsupported' }, { value: 'q_west' }); + expect(screen.queryByTestId('two-source-pick')).toBeNull(); + expect(screen.getByDisplayValue('q_west')).toBeInTheDocument(); + expect(state.metaList).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts b/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts index 273a3629962..156e49e94dc 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts +++ b/ui/packages/app-shell/src/views/metadata-admin/inspectors/json-schema-to-fields.ts @@ -139,6 +139,11 @@ const REFERENCE_KINDS: ReadonlySet = new Set([ * a server on that spec binds `position` to it, and dropping it sent the * picker back to the local fallback — under the installed spec a lookup over * `sys_position` rows, a mirror a fresh environment leaves empty. + * + * `registry-and-data` (ADR-0142 D6) is read too: a server on that spec binds + * `queue` to it, and the queue picker lists both of its halves. It is kept only + * whole — a type, an object and a committed column — because one half alone + * would offer half of what the value can name. */ function sourceOf(raw: unknown): RefValueSource | undefined { if (!raw || typeof raw !== 'object') return undefined; @@ -151,6 +156,13 @@ function sourceOf(raw: unknown): RefValueSource | undefined { if (s.source === 'registry') { return typeof s.type === 'string' && s.type ? { source: 'registry', type: s.type } : undefined; } + if (s.source === 'registry-and-data') { + return typeof s.type === 'string' && s.type + && typeof s.object === 'string' && s.object + && typeof s.valueField === 'string' && s.valueField + ? { source: 'registry-and-data', type: s.type, object: s.object, valueField: s.valueField } + : undefined; + } if (s.source === 'enum') { const values = Array.isArray(s.values) ? s.values.filter((v): v is string => typeof v === 'string') : []; return values.length ? { source: 'enum', values } : undefined; diff --git a/ui/packages/fields/src/widgets/RecipientPickerField.queue-25769.test.tsx b/ui/packages/fields/src/widgets/RecipientPickerField.queue-25769.test.tsx new file mode 100644 index 00000000000..4476411edf8 --- /dev/null +++ b/ui/packages/fields/src/widgets/RecipientPickerField.queue-25769.test.tsx @@ -0,0 +1,126 @@ +/** + * 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. + */ + +/** + * The `queue` sharing-rule recipient (objectstack ADR-0142 D7) is picked from + * BOTH of a queue's sources: the queues a package declares (the console + * metadata store's `queue` items) and the organization's own `sys_queue` rows + * (the data source). One machine name is stored either way. + * + * Pinned here: the render condition (both readers, or the text input), the two + * requests, the merge (declared first, by name), and the committed value. + */ +import { describe, it, expect, vi } from 'vitest'; +import React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { MetadataCtx, type MetadataContextValue } from '@object-ui/react'; +import { RecipientPickerField } from './RecipientPickerField'; +import { mergeQueueCandidates, queueCandidateLabel } from './queueCandidates'; + +const DECLARED = [{ name: 'support_intake', label: 'Support Intake' }]; +const AUTHORED = [ + { id: 'q_1', name: 'shanghai_desk', label: 'Shanghai Desk' }, + // A row taking a declared queue's name: the server refuses this write, and + // if one is read anyway the declared queue wins, as the server resolves it. + { id: 'q_2', name: 'support_intake', label: 'Shadowing Row' }, +]; + +function metadataStore(items: unknown[]): MetadataContextValue & { ensureType: ReturnType } { + return { + apps: [], objects: [], dashboards: [], reports: [], pages: [], + loading: false, error: null, + refresh: async () => {}, invalidate: () => {}, + ensureType: vi.fn().mockResolvedValue(items), + getItem: async () => null, + getItemsByType: () => [], + } as any; +} + +function dataSourceWith(rows: unknown[]) { + return { find: vi.fn().mockResolvedValue({ data: rows }) } as any; +} + +function renderQueuePicker(store: MetadataContextValue | null, ds: any, value = '', onChange = vi.fn()) { + const picker = ( + + ); + return render(store ? {picker} : picker); +} + +describe('mergeQueueCandidates — one list from two sources (ADR-0142 D1)', () => { + it('merges declared items and organization rows by name, declared first, ordered by name', () => { + expect(mergeQueueCandidates(DECLARED, { data: AUTHORED })).toEqual([ + { name: 'shanghai_desk', label: 'Shanghai Desk', source: 'organization' }, + { name: 'support_intake', label: 'Support Intake', source: 'declared' }, + ]); + }); + + it('reads every result shape a data source returns and skips nameless rows', () => { + const rows = [{ name: 'a_desk' }, { label: 'no name' }, null]; + expect(mergeQueueCandidates([], rows).map((c) => c.name)).toEqual(['a_desk']); + expect(mergeQueueCandidates([], { records: rows }).map((c) => c.name)).toEqual(['a_desk']); + expect(mergeQueueCandidates(undefined, undefined)).toEqual([]); + }); + + it('labels a queue with its machine name beside a differing label', () => { + expect(queueCandidateLabel({ name: 'support_intake', label: 'Support Intake', source: 'declared' })) + .toBe('Support Intake (support_intake)'); + expect(queueCandidateLabel({ name: 'a_desk', label: 'a_desk', source: 'organization' })).toBe('a_desk'); + }); +}); + +describe('RecipientPickerField — a queue recipient lists both sources (ADR-0142 D7)', () => { + it('asks the registry for `queue` items and the data API for `sys_queue` rows', async () => { + const store = metadataStore(DECLARED); + const ds = dataSourceWith(AUTHORED); + renderQueuePicker(store, ds); + + await waitFor(() => expect(store.ensureType).toHaveBeenCalledWith('queue')); + expect(ds.find).toHaveBeenCalledWith('sys_queue', { $top: 500, $orderby: { name: 'asc' } }); + fireEvent.click(screen.getByRole('combobox')); + expect(await screen.findByText('Support Intake')).toBeInTheDocument(); + expect(screen.getByText('Shanghai Desk')).toBeInTheDocument(); + // The shadowing row never displaces the declared queue of the same name. + expect(screen.queryByText('Shadowing Row')).not.toBeInTheDocument(); + }); + + it('stores the chosen queue by its machine name, whichever source it came from', async () => { + const store = metadataStore(DECLARED); + const onChange = vi.fn(); + renderQueuePicker(store, dataSourceWith(AUTHORED), '', onChange); + + await waitFor(() => expect(store.ensureType).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('combobox')); + fireEvent.click(await screen.findByText('Shanghai Desk')); + expect(onChange).toHaveBeenCalledWith('shanghai_desk'); + }); + + it('still offers the other source when one read fails', async () => { + const store = metadataStore(DECLARED); + const ds = { find: vi.fn().mockRejectedValue(new Error('403')) } as any; + renderQueuePicker(store, ds); + + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + fireEvent.click(screen.getByRole('combobox')); + expect(await screen.findByText('Support Intake')).toBeInTheDocument(); + }); + + it('degrades to the text input when either reader is missing — half a list is not offered', () => { + const ds = dataSourceWith(AUTHORED); + renderQueuePicker(null, ds, 'support_intake'); + expect(screen.getByDisplayValue('support_intake')).toBeInTheDocument(); + expect(ds.find).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/plugin-detail/src/queueClaim-25769.test.tsx b/ui/packages/plugin-detail/src/queueClaim-25769.test.tsx new file mode 100644 index 00000000000..2f615bfc7e0 --- /dev/null +++ b/ui/packages/plugin-detail/src/queueClaim-25769.test.tsx @@ -0,0 +1,175 @@ +/** + * 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. + */ + +/** + * The Claim affordance on a record sitting in a queue (objectstack ADR-0142 + * D3/D4). The server is the only judge, so what is pinned here is that the + * client asks it and reports its answer, never that it decides: + * + * - the render condition: a record SITS in a queue only with no owner and a + * named queue; only then is the server asked; + * - the request: `POST /api/v1/security/explain` with `operation: 'transfer'` + * for THIS record, and the button only on a `queue_claim` rule that + * `admits` — any other answer, a refusal, or no answer leaves no button; + * - the write: `{ owner_id: }` and nothing else, with a lost race's + * `409 CONCURRENT_UPDATE` reported as "taken", not as a generic failure. + */ +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, waitFor } from '@testing-library/react'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; +import { + recordSitsInQueue, + queueClaimAdmitted, + useQueueClaimOffered, + claimQueuedRecord, +} from './queueClaim'; + +function me(userId: string): MePermissionsResponse { + return { + authenticated: true, + userId, + tenantId: 't1', + roles: [], + permissionSets: [], + objects: { ticket: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } }, + fields: {}, + }; +} + +function signedInAs(userId: string) { + return ({ children }: { children: React.ReactNode }) => ( + {children} + ); +} + +/** An explain decision whose sharing layer reports the claim attribution. */ +function decision(effect: 'admits' | 'neutral') { + return { + allowed: true, + layers: [ + { layer: 'object', record: { rules: [] } }, + { layer: 'sharing', record: { rules: [{ kind: 'queue_claim', effect, queue: 'support_intake' }] } }, + ], + }; +} + +function explainAnswering(body: unknown, ok = true) { + return vi.fn(async () => ({ ok, json: async () => body })) as any; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('recordSitsInQueue — is there anything to ask about? (ADR-0142 D3)', () => { + it('names the queue of an unowned record with a queue', () => { + expect(recordSitsInQueue({ owner_id: null, queue: 'support_intake' })).toBe('support_intake'); + expect(recordSitsInQueue({ owner_id: '', queue: 'support_intake' })).toBe('support_intake'); + expect(recordSitsInQueue({ queue: 'support_intake' })).toBe('support_intake'); + }); + + it('answers null for an owned record (its queue is only its home), and for no queue', () => { + expect(recordSitsInQueue({ owner_id: 'u_1', queue: 'support_intake' })).toBeNull(); + expect(recordSitsInQueue({ owner_id: null, queue: null })).toBeNull(); + expect(recordSitsInQueue({ owner_id: null, queue: '' })).toBeNull(); + expect(recordSitsInQueue(null)).toBeNull(); + }); +}); + +describe('queueClaimAdmitted — reads the server’s claim attribution, nothing else', () => { + it('is true only on a queue_claim rule that admits', () => { + expect(queueClaimAdmitted(decision('admits'))).toBe(true); + expect(queueClaimAdmitted(decision('neutral'))).toBe(false); + }); + + it('does not read a visible transfer or a membership rule as a claim admission', () => { + expect(queueClaimAdmitted({ allowed: true, record: { recordId: 'r1', visible: true } })).toBe(false); + expect(queueClaimAdmitted({ + layers: [{ record: { rules: [{ kind: 'queue_membership', effect: 'admits' }] } }], + })).toBe(false); + }); + + it('is false on a malformed answer', () => { + expect(queueClaimAdmitted(null)).toBe(false); + expect(queueClaimAdmitted({ layers: 'x' })).toBe(false); + expect(queueClaimAdmitted({ layers: [null, { record: null }] })).toBe(false); + }); +}); + +describe('useQueueClaimOffered — the button renders only where the server admits the claim', () => { + it('asks the explain engine about a transfer of THIS record, and offers on admits', async () => { + const fetchSpy = explainAnswering(decision('admits')); + vi.stubGlobal('fetch', fetchSpy); + const { result } = renderHook(() => useQueueClaimOffered('ticket', 'r1', true), { wrapper: signedInAs('u_me') }); + + await waitFor(() => expect(result.current).toBe(true)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe('/api/v1/security/explain'); + expect(init.method).toBe('POST'); + expect(JSON.parse(init.body)).toEqual({ object: 'ticket', operation: 'transfer', recordId: 'r1' }); + }); + + it('offers nothing when the server does not admit the claim', async () => { + const fetchSpy = explainAnswering(decision('neutral')); + vi.stubGlobal('fetch', fetchSpy); + const { result } = renderHook(() => useQueueClaimOffered('ticket', 'r1', true), { wrapper: signedInAs('u_me') }); + + await waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(1)); + expect(result.current).toBe(false); + }); + + it('fails closed: a refused or failed probe leaves no button', async () => { + const refused = explainAnswering(decision('admits'), false); + vi.stubGlobal('fetch', refused); + const a = renderHook(() => useQueueClaimOffered('ticket', 'r1', true), { wrapper: signedInAs('u_me') }); + await waitFor(() => expect(refused).toHaveBeenCalledTimes(1)); + expect(a.result.current).toBe(false); + + const broken = vi.fn(async () => { throw new Error('network'); }) as any; + vi.stubGlobal('fetch', broken); + const b = renderHook(() => useQueueClaimOffered('ticket', 'r2', true), { wrapper: signedInAs('u_me') }); + await waitFor(() => expect(broken).toHaveBeenCalledTimes(1)); + expect(b.result.current).toBe(false); + }); + + it('asks nothing for a record that sits in no queue, or with no known user', async () => { + const fetchSpy = explainAnswering(decision('admits')); + vi.stubGlobal('fetch', fetchSpy); + const notSitting = renderHook(() => useQueueClaimOffered('ticket', 'r1', false), { wrapper: signedInAs('u_me') }); + const anonymous = renderHook(() => useQueueClaimOffered('ticket', 'r1', true)); + await new Promise((r) => setTimeout(r, 20)); + expect(fetchSpy).not.toHaveBeenCalled(); + expect(notSitting.result.current).toBe(false); + expect(anonymous.result.current).toBe(false); + }); +}); + +describe('claimQueuedRecord — one write, the server’s answer reported as given (ADR-0142 D4)', () => { + it('sends { owner_id: } and nothing else through update', async () => { + const writer = { update: vi.fn(async () => ({})) }; + await expect(claimQueuedRecord(writer, 'ticket', 'r1', 'u_me')).resolves.toEqual({ outcome: 'claimed' }); + expect(writer.update).toHaveBeenCalledWith('ticket', 'r1', { owner_id: 'u_me' }); + }); + + it('reports a lost race (409 CONCURRENT_UPDATE) as taken', async () => { + const lost = Object.assign( + new Error('The record was taken: another user changed it first. Reload it to see who has it now.'), + { name: 'ConcurrentUpdateError', code: 'CONCURRENT_UPDATE' }, + ); + const writer = { update: vi.fn(async () => { throw lost; }) }; + await expect(claimQueuedRecord(writer, 'ticket', 'r1', 'u_me')).resolves.toEqual({ outcome: 'taken' }); + }); + + it('carries any other refusal through as refused, with its error', async () => { + const denied = Object.assign(new Error('Forbidden'), { status: 403 }); + const writer = { update: vi.fn(async () => { throw denied; }) }; + await expect(claimQueuedRecord(writer, 'ticket', 'r1', 'u_me')).resolves.toEqual({ outcome: 'refused', error: denied }); + }); +}); diff --git a/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx new file mode 100644 index 00000000000..ef83061eb0f --- /dev/null +++ b/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx @@ -0,0 +1,126 @@ +/** + * 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. + */ + +/** + * objectstack ADR-0142 D3 — the "In a queue" list filter: the records that + * SIT IN an ownership queue (`owner_id` empty and `queue` named). + * + * Pinned: + * - the render condition: the toggle appears only when the SERVED object + * definition carries the platform-injected `queue` column (`system: true`); + * an object without it, or with an author's own non-system `queue` field, + * gets no toggle; + * - the request: pressing it AND-s exactly `owner_id is_null` and `queue + * is_not_null` into the fetch filter, and releasing it drops them. Nothing + * about who may see the rows: that stays the server's read filter. + */ + +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, waitFor, cleanup, fireEvent, screen } from '@testing-library/react'; +import { ListView, buildEffectiveFilter, objectHasOwnershipQueue, QUEUE_SITTING_CONDITIONS } from '../ListView'; +import { SchemaRendererProvider } from '@object-ui/react'; +import type { ListViewSchema } from '@object-ui/types'; + +afterEach(cleanup); + +const OBJECT = 'support_ticket'; + +/** The injected column, as `/meta` serves it on an ownership-queue object. */ +const INJECTED_QUEUE = { name: 'queue', type: 'text', system: true, hidden: true }; + +function dataSourceServing(fields: Record) { + return { + find: vi.fn().mockResolvedValue([]), + findOne: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + getObjectSchema: vi.fn().mockResolvedValue({ name: OBJECT, fields: { subject: { name: 'subject', type: 'text' }, ...fields } }), + }; +} + +const schema = { type: 'list-view', objectName: OBJECT, fields: ['subject'] } as unknown as ListViewSchema; + +function mount(ds: ReturnType) { + return render( + + + , + ); +} + +const lastFilter = (ds: ReturnType) => { + const calls = ds.find.mock.calls; + return (calls[calls.length - 1]?.[1] as { $filter?: unknown } | undefined)?.$filter; +}; + +describe('objectHasOwnershipQueue — reads the served definition only', () => { + it('is true for the injected system column, in map or array form', () => { + expect(objectHasOwnershipQueue({ fields: { queue: INJECTED_QUEUE } })).toBe(true); + expect(objectHasOwnershipQueue({ fields: [{ name: 'subject' }, INJECTED_QUEUE] })).toBe(true); + }); + + it('is false with no queue column, or an author\'s own non-system one', () => { + expect(objectHasOwnershipQueue({ fields: { subject: { type: 'text' } } })).toBe(false); + expect(objectHasOwnershipQueue({ fields: { queue: { name: 'queue', type: 'text' } } })).toBe(false); + expect(objectHasOwnershipQueue(undefined)).toBe(false); + }); +}); + +describe('buildEffectiveFilter — the queue conditions are AND-ed like the rest', () => { + const NONE = { logic: 'and', conditions: [] } as any; + + it('alone, they are the whole filter', () => { + expect(buildEffectiveFilter(undefined, NONE, [], QUEUE_SITTING_CONDITIONS)).toEqual([ + 'and', ['owner_id', 'is_null', true], ['queue', 'is_not_null', true], + ]); + }); + + it('beside an authored filter, they narrow it', () => { + expect(buildEffectiveFilter(['status', '=', 'open'], NONE, [], QUEUE_SITTING_CONDITIONS)).toEqual([ + 'and', ['status', '=', 'open'], ['owner_id', 'is_null', true], ['queue', 'is_not_null', true], + ]); + }); + + it('absent by default, so every other caller is unchanged', () => { + expect(buildEffectiveFilter(['status', '=', 'open'], NONE, [])).toEqual(['status', '=', 'open']); + }); +}); + +describe('ListView "In a queue" toggle (ADR-0142 D3)', () => { + it('renders on an object served with the injected queue column, and narrows the fetch to parked rows', async () => { + const ds = dataSourceServing({ queue: INJECTED_QUEUE }); + mount(ds); + + const toggle = await screen.findByTestId('queue-sitting-filter'); + expect(toggle.getAttribute('aria-pressed')).toBe('false'); + expect(toggle.textContent).toContain('In a queue'); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(JSON.stringify(lastFilter(ds) ?? null)).not.toContain('owner_id'); + + const before = ds.find.mock.calls.length; + fireEvent.click(toggle); + await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(before)); + expect(screen.getByTestId('queue-sitting-filter').getAttribute('aria-pressed')).toBe('true'); + expect(lastFilter(ds)).toEqual(['and', ['owner_id', 'is_null', true], ['queue', 'is_not_null', true]]); + + const pressed = ds.find.mock.calls.length; + fireEvent.click(screen.getByTestId('queue-sitting-filter')); + await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(pressed)); + expect(JSON.stringify(lastFilter(ds) ?? null)).not.toContain('owner_id'); + }); + + it('does not render on an object without the injected column', async () => { + const ds = dataSourceServing({ queue: { name: 'queue', type: 'text' } }); + mount(ds); + await waitFor(() => expect(ds.getObjectSchema).toHaveBeenCalled()); + await waitFor(() => expect(ds.find).toHaveBeenCalled()); + expect(screen.queryByTestId('queue-sitting-filter')).toBeNull(); + }); +}); From 15effb16fbbc0a9e15ca061a8276cc61458e9b92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 11:57:12 +0000 Subject: [PATCH 4/7] docs: queues and assignment rules authoring guide, linked from approvals, sharing rules and RLS (ADR-0142) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz --- .changeset/25769-console-queue-affordances.md | 19 ++ content/docs/automation/approvals.mdx | 4 +- content/docs/automation/index.mdx | 2 + content/docs/automation/meta.json | 1 + .../queues-and-assignment-rules.mdx | 165 ++++++++++++++++++ content/docs/permissions/rls.mdx | 1 + content/docs/permissions/sharing-rules.mdx | 3 +- scripts/docs-audit/handwritten-docs.json | 1 + 8 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 .changeset/25769-console-queue-affordances.md create mode 100644 content/docs/automation/queues-and-assignment-rules.mdx diff --git a/.changeset/25769-console-queue-affordances.md b/.changeset/25769-console-queue-affordances.md new file mode 100644 index 00000000000..7834fdd5721 --- /dev/null +++ b/.changeset/25769-console-queue-affordances.md @@ -0,0 +1,19 @@ +--- +'@object-ui/fields': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +'@object-ui/plugin-list': patch +'@object-ui/i18n': patch +--- + +feat(console): ownership queues in the Console — a queue picker reading both sources, a Claim button the server decides, and an "In a queue" list filter + +Clause-②: no +Metadata-protocol: none + +The server half of ownership queues shipped without a Console face. Now: + +- **Queue pickers list both sources.** The flow designer's approver Value for a `queue` approver, and a sharing rule's `queue` recipient, list the queues an installed app declares (`GET /api/v1/meta/queue`) together with the organization's own `sys_queue` records, one list by machine name, and store the name. The approver picker follows the binding the server publishes (`registry-and-data`); with no data adapter, or a server that publishes another binding, it keeps the text box. +- **Claim.** A record sitting in a queue (no owner, a `queue` named) shows **Claim** in its header only when the explain engine's answer for a transfer of that record carries an admitting `queue_claim` rule; no answer means no button. Claim sends `{ owner_id: }` through the normal update, and a lost race (`409 CONCURRENT_UPDATE`) is reported as "Someone else claimed this record first." +- **In a queue.** A list of an object served with the injected `queue` column gains an **In a queue** toggle that narrows the fetch (and an export) to `owner_id` empty and `queue` set. Which of those rows come back is still the server's read filter. +- **Docs.** A new authoring guide, *Queues and assignment rules*, covers declaring a queue and an assignment rule, the `load` requirement, what a parked record is and who sees it, how a claim is spelled, and that a queue is not a messaging audience; the approvals, sharing-rule and RLS pages link to it. diff --git a/content/docs/automation/approvals.mdx b/content/docs/automation/approvals.mdx index 9a7e0b7439c..5405a9b809b 100644 --- a/content/docs/automation/approvals.mdx +++ b/content/docs/automation/approvals.mdx @@ -408,7 +408,9 @@ one that silently resolves to nobody when it's mistaken for a business hierarchy. A `queue` entry names an ownership queue by its machine name — one an installed app declares, or one of the organization's own — and resolves to the queue's members at node entry, through the same expansion that decides who -may claim the queue's records (ADR-0142 D6). `field`, `manager`, and `expression` resolve against the record's +may claim the queue's records (ADR-0142 D6) — how to declare a queue, and what +its members can do with the records parked in it, is in +[Queues and assignment rules](/docs/automation/queues-and-assignment-rules). `field`, `manager`, and `expression` resolve against the record's **live** state at node entry (#3447); `manager` additionally depends on `sys_user.manager_id`, which a platform admin sets from **Setup → Users → ⋯ → Set Manager** — see [the `manager` callout above](#3-the-approval-node). `position`, `department`, diff --git a/content/docs/automation/index.mdx b/content/docs/automation/index.mdx index 2591698d3cc..00fd788a2bf 100644 --- a/content/docs/automation/index.mdx +++ b/content/docs/automation/index.mdx @@ -31,6 +31,7 @@ export const OpportunityStageHook: Hook = { - **Flows** are node-based processes built from nodes like decision, loop, record operations, http, notify, script, screen, wait, subflow, and parallel/join gateways — and the set is **registry-extensible**: plugins can contribute new node types via `registerNodeExecutor`. Flows launch on record changes, on a schedule, from a screen, or via API ([Flows](/docs/automation/flows)). - **Workflows** model a record's lifecycle as a **finite state machine**: states, transitions, and guards ([Workflows](/docs/automation/workflows)). - **Approvals** are flow nodes with approver resolution, approve/reject decisions, and escalation ([Approvals](/docs/automation/approvals)). +- **Queues and assignment rules** route a new, ownerless record to a team: declared rules park it in a queue for a member to claim, or hand it to one member round-robin or by least load — no `beforeInsert` hook picks the owner ([Queues & assignment](/docs/automation/queues-and-assignment-rules)). - **Webhooks** deliver events to external systems through a **durable outbox** — exponential/linear/fixed retry with dead-lettering, HMAC signing, and an admin redeliver endpoint ([Webhook Delivery](/docs/automation/webhooks)). - **Connectors** package external systems behind named actions that flows dispatch — registered by plugins, or **declared as pure metadata** (`provider: 'rest' | 'openapi' | 'mcp'`) and materialized at boot, with reference-based credentials ([Connectors](/docs/automation/connectors)). - **Scheduled jobs** run one named bundle function on a cron, interval, or one-off schedule, with retry, a per-attempt timeout, and cluster leader election — a `schedule`-type flow is registered against the *same* job service, so the choice between them is about what runs, not about timing ([Scheduled Jobs](/docs/automation/jobs)). @@ -46,6 +47,7 @@ Rule of thumb: model *state* with workflows, model *steps* with flows, use hooks + diff --git a/content/docs/automation/meta.json b/content/docs/automation/meta.json index c54511d22ee..31afc30a56b 100644 --- a/content/docs/automation/meta.json +++ b/content/docs/automation/meta.json @@ -6,6 +6,7 @@ "flows", "workflows", "approvals", + "queues-and-assignment-rules", "webhooks", "connectors", "jobs", diff --git a/content/docs/automation/queues-and-assignment-rules.mdx b/content/docs/automation/queues-and-assignment-rules.mdx new file mode 100644 index 00000000000..ae67dad1450 --- /dev/null +++ b/content/docs/automation/queues-and-assignment-rules.mdx @@ -0,0 +1,165 @@ +--- +title: Queues and assignment rules — route new work to a team +navTitle: Queues & assignment +description: Declare an ownership queue and an assignment rule, park new records for a team to claim or hand them to the least-loaded member, and know who sees a parked record. +--- + +## Scenario + +> New cases (leads, tickets, requests) arrive with no owner — from a public form, an import, an integration. They should land with **a team**, and either go straight to one of its members or wait until someone takes them. How do I say that without writing a `beforeInsert` hook that picks an owner? + +## Recommended solution + +Two declarations, both in your stack, and no hook: + +1. A **queue** — a named set of people that records can wait in (ADR-0142 D1). +2. An **assignment rule** — per object, which inserted rows go to which queue (or user), and how one member is chosen (ADR-0142 D5). + +The platform does the rest: it stamps the record's owner or parks it in the queue, lets the queue's members see what is parked, and turns "I'll take this one" into one ordinary update that only one person can win. + +Queues and assignment rules apply to objects with `ownership: 'user'` — the tier that has an `owner_id`. Both keys need the ownership-queue service, which `@objectstack/plugin-sharing` registers; a stack that declares either without it is **refused at boot**, naming the key and the missing service, rather than served with queues nothing resolves. + +### 1. Declare a queue + +```ts +import { defineStack } from '@objectstack/spec'; +import { defineQueue } from '@objectstack/spec/security'; + +export default defineStack({ + // …objects, positions, … + queues: [ + defineQueue({ + name: 'support_intake', // the one name everything references + label: 'Support Intake', + members: [{ type: 'position', value: 'service_agent' }], + }), + defineQueue({ + name: 'escalations', + label: 'Escalations', + members: [{ type: 'position', value: 'support_lead' }], + }), + ], +}); +``` + +A queue's **members** are written with the recipient kinds a sharing rule already uses, and resolve in the organization a record belongs to. A **declared** queue names **positions only**: a position is a catalog item your package can know by name, while a user, a team or a business unit is an organization's own row. + +An organization staffs its own queues too. A `sys_queue` record (with `sys_queue_member` records naming any of `user`, `team`, `position`, `business_unit` or `unit_and_subordinates`) is a queue exactly like a declared one. Both live in **one namespace, by name**: a `sys_queue` record may not take a declared queue's name, and every reference — the record's `queue` column, an assignment rule, an approver, a sharing recipient — resolves the name registry-first, then the organization's records. A declared queue is switched off through the activation ledger like any other declared metadata. + +An empty member list is allowed: it is a queue nobody staffs yet, and records still sit in it. + +### 2. Declare an assignment rule + +```ts +import { defineAssignmentRule } from '@objectstack/spec/automation'; + +export default defineStack({ + // … + assignmentRules: [ + defineAssignmentRule({ + name: 'case_intake', + object: 'crm_case', + entries: [ + { + name: 'vip_to_escalations', + criteria: 'record.priority == "urgent"', + assignTo: { type: 'queue', value: 'escalations' }, // no strategy: park it + }, + { + name: 'everyone_else', // no criteria: every row + assignTo: { type: 'queue', value: 'support_intake' }, + strategy: 'least_loaded', + load: 'record.status != "resolved" && record.status != "closed"', + }, + ], + }), + ], +}); +``` + +**Entries are ordered; the first whose `criteria` match wins.** `criteria` is CEL over `record.*`, read against the row being inserted. `assignTo` is a queue (by machine name) or one user (by id). + +**`strategy`** decides what happens inside a queue: + +| `strategy` | The row ends up | +|:--|:--| +| *(absent)* | **Parked**: `queue` set, `owner_id` empty, until a member claims it | +| `least_loaded` | Owned by the member with the fewest rows matching `load`; a tie goes to the earlier member (members are ordered by user id) | +| `round_robin` | Owned by the member after the one this entry chose last | + +`strategy` applies only to a queue target; on a `user` target it is refused. + +**When it runs.** Once per inserted row, after every `beforeInsert` hook and before the write, and only for a row whose `owner_id` and `queue` are both still empty. A row whose writer named an owner, or whose hook set or cleared one, is left alone. **An update never runs a rule.** Assignment never refuses an insert: an empty queue parks the row, and a queue that does not resolve in the row's organization, or a read that fails, leaves the row ownerless — each logged once at `warn`, naming the queue. + +The rule writes `owner_id` (when it chose a member) and `queue` (for a queue target), nothing else. The row's audit columns still record who inserted it; the owner the rule chose is ownership, not attribution. + +### 3. `load` is required with `least_loaded` + +`least_loaded` counts each member's **open** rows of the object, and only you know what "open" means for it — `status != "closed"` for cases, `is_converted == false` for leads. So `load` is **required** with `least_loaded` and has no default: a default such as "every row" would count closed work and quietly make the busiest historical owner look the most loaded. `load` on any other strategy is refused, because nothing reads it. + +`load` is a **pushdown** predicate: it is lowered to a query filter, so it may use only what the CEL → filter compiler can lower (comparisons, `&&`, `||`, `in` over the object's own columns). A `load` the compiler cannot lower is an authoring-time error, never a rule that silently counts everything. `criteria`, by contrast, is read against the in-memory row and may use any CEL. + +## What a parked record is, and who sees it + +A record **sits in** queue Q when its `owner_id` is empty and its `queue` column names Q. Every `ownership: 'user'` object carries that `queue` column — a system-managed text column the platform injects beside `owner_id` (an object that declares its own field named `queue` is refused at registration). Once a record has an owner, `queue` only names its **home queue**: where a release returns it, and a reporting dimension. It grants nothing. + +**Who sees a parked record:** + +- **The members of Q** read and edit what sits in Q, through the reserved membership set `current_user.queues` (the queues the caller belongs to in the active organization). This widens **rows, never verbs**: a member still needs the object's read and edit grants, and **delete is never conferred** by queue membership. +- **Everyone who could see it anyway** — scope depth, `viewAllRecords` / `modifyAllRecords`, an applicable sharing rule or record share — still does. +- **Nobody through ownership.** A parked record is "owned by nobody": it is in no one's "my records" view, and an `owner_of:` notification audience reaches no one. + +The moment someone claims it, the queue path stops applying: the other members lose access unless another basis grants it. Ask the explain endpoint (`POST /api/v1/security/explain`) why a user sees a record and it reports the queue basis as its own reason (`queue_membership`) — see [Explaining a decision](/docs/permissions/explain). + +In the Console, a list of an object that carries the `queue` column has an **In a queue** toggle in its toolbar. It narrows the list to parked records (`owner_id` empty and `queue` set); which of them you see is still the server's read filter, so a member sees what sits in their own queues. + +## How a claim is spelled + +A **claim** is an ordinary update of `owner_id` to your own user id, by record id: + +```http +PATCH /api/v1/data/crm_case/cas_01J8… +Content-Type: application/json + +{ "owner_id": "" } +``` + +There is no claim endpoint and no claim hook. The ownership gate admits this one update when the record is **unowned**, the payload names **you** as a single value, the request addresses **one record by id**, and the sharing model positively admits you to the record — a member of its queue, a holder of an `edit` share, or `modifyAllRecords`. A bulk update carrying `owner_id` stays refused. + +**Only one person wins.** The write is conditional on the owner still being empty, so two people claiming at once produce exactly one owner. The loser gets **`409 CONCURRENT_UPDATE`** — "The record was taken: another user changed it first. Reload it to see who has it now." Treat it as an answer, not an error: reload and move on. Any other refusal is `403 PERMISSION_DENIED`, and its message names the condition that failed. + +The Console's **Claim** button on a record sends exactly this update. It appears only on a record that sits in a queue, and only after the server's explain answer says your claim would be admitted; when someone beats you to it, it says so. + +Two related writes go through the same gate: + +- **Release** — the current owner sets `owner_id` back to `null` and leaves `queue` unchanged; the record sits in its home queue again. A record with no home queue has nowhere to return to, so releasing it is an ordinary disown that needs the transfer grant. +- **Move** — writing a different `queue` (to re-route a parked record, or to park an owned one with `owner_id: null` in the same write) is a transfer: it needs `allowTransfer` or `modifyAllRecords`, and a queue that does not resolve in the record's organization is refused with `QUEUE_NOT_FOUND`. + +## A queue elsewhere: approvers and sharing recipients + +The same queue name works in two more places, each resolving to the queue's members: + +- **An approval node's approver** — `{ type: 'queue', value: 'support_intake' }` puts the queue's members on the slate at node entry; see [Approval chains](/docs/automation/approvals). +- **A sharing rule's recipient** — `sharedWith: { type: 'queue', value: 'support_intake' }` shares the rule's matching records with the queue's members, re-materialised as membership moves; see [Sharing rules](/docs/permissions/sharing-rules#recipient-types). + +In the Console both pickers list the declared queues and the organization's own `sys_queue` records together. Note what a sharing rule does **not** do: naming a queue as a recipient shares *the rule's records* with *the queue's people*. It does not make records sit in the queue; only the `queue` column does that. + +## A queue is not a messaging audience + +A queue routes **records** to people. It is not an address for **messages**: a flow's `notify` audience grammar (`user:`, `role:`, `team:`, `owner_of:`) has no `queue:` selector, so "tell the queue that something new arrived" cannot be written as a queue. Address the people who staff it instead — the team, or the users holding the position the queue's members name — and remember that `owner_of:` on a parked record reaches nobody, because nobody owns it yet. + +## Checklist + +- The object is `ownership: 'user'`, and declares no field of its own named `queue`. +- The stack loads `@objectstack/plugin-sharing`; otherwise `queues` / `assignmentRules` refuse the boot. +- A declared queue's members are positions; organizations add users, teams and units through `sys_queue_member`. +- Every `least_loaded` entry has a `load` your filter compiler can lower; no other entry has one. +- Code that claims a record sends `{ owner_id: }` by id and handles `409` as "someone else took it". +- Alerts about new work address a team or a position, not the queue. + +## See also + +- [Assignment rule reference](/docs/references/automation/assignment-rule) — the generated schema. +- [Approval chains](/docs/automation/approvals) — the `queue` approver. +- [Sharing rules](/docs/permissions/sharing-rules) — recipient kinds, including `queue`. +- [Row-level security](/docs/permissions/rls) — reserved membership keys such as `current_user.queues`. diff --git a/content/docs/permissions/rls.mdx b/content/docs/permissions/rls.mdx index 43f5d65bb4e..b4f833610dc 100644 --- a/content/docs/permissions/rls.mdx +++ b/content/docs/permissions/rls.mdx @@ -164,6 +164,7 @@ not this one): | `current_user.organization_id` | The acting tenant | | `current_user.positions` | The user's positions (array) | | `current_user.org_user_ids` | User ids in the user's org (array) | +| `current_user.queues` | The ownership queues the user belongs to in the active organization (array of queue names) — the set that lets a queue's members see the records parked in it ([Queues and assignment rules](/docs/automation/queues-and-assignment-rules)) | | `current_user.email` | The user's email | | `current_user.KEY` | A set a roster object declares under [`membershipKeys`](/docs/references/security/rls#where-a-membership-key-comes-from) (array, read with `in`) | diff --git a/content/docs/permissions/sharing-rules.mdx b/content/docs/permissions/sharing-rules.mdx index b5bb3f84d7c..6e36df3125a 100644 --- a/content/docs/permissions/sharing-rules.mdx +++ b/content/docs/permissions/sharing-rules.mdx @@ -177,7 +177,7 @@ export const AccountTeamSharingRule = defineSharingRule({ `sharedWith` accepts a `{ type, value }` recipient. Every recipient expands to concrete users and materializes `sys_record_share` grants — once per **rule** -for the first five, once per **matched record** for `field`: +for the first six, once per **matched record** for `field`: | `type` | Shares with | |:--|:--| @@ -186,6 +186,7 @@ for the first five, once per **matched record** for `field`: | `position` | Everyone assigned that position (flat expansion — positions have no tree) | | `unit_and_subordinates` | Everyone in that **business unit and every unit beneath it** (the BU tree is the one hierarchy — ADR-0090 D3) | | `business_unit` | Everyone in exactly that business unit (no subtree) | +| `queue` | The members of an ownership queue, named by its machine name — one an installed app declares, or one of the organization's own `sys_queue` records — re-materialised when the queue's membership moves. It shares the rule's matching records with the queue's people; it does **not** park records in the queue, which only the record's `queue` column does ([Queues and assignment rules](/docs/automation/queues-and-assignment-rules)) | | `field` | The user or users named by a **user-typed field on each matched record** — `value` is that field's name (`assignees`). A `multiple: true` field shares with every user it holds; an empty column shares with nobody. There is deliberately no `manager` recipient: "the owner's manager" is a user field the application stores on the record, named here (maintainer ruling 2026-09-02, objectstack#14103). Expanded once per matched record by the sharing-rule executor (objectstack#15072), and re-materialised when the record's own write changes that field | A criteria `condition` must be compilable by the CEL → filter pushdown diff --git a/scripts/docs-audit/handwritten-docs.json b/scripts/docs-audit/handwritten-docs.json index 63761bff8f3..7284fea07d1 100644 --- a/scripts/docs-audit/handwritten-docs.json +++ b/scripts/docs-audit/handwritten-docs.json @@ -49,6 +49,7 @@ "content/docs/automation/hooks.mdx", "content/docs/automation/index.mdx", "content/docs/automation/jobs.mdx", + "content/docs/automation/queues-and-assignment-rules.mdx", "content/docs/automation/webhooks.mdx", "content/docs/automation/workflows.mdx", "content/docs/build-without-code.mdx", From 4dde047e8bd8b794528990da2dada581b2939915 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 12:53:02 +0000 Subject: [PATCH 5/7] docs: name the notify audience selectors without the reserved word Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz --- content/docs/automation/queues-and-assignment-rules.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/automation/queues-and-assignment-rules.mdx b/content/docs/automation/queues-and-assignment-rules.mdx index ae67dad1450..e7ee9d935f9 100644 --- a/content/docs/automation/queues-and-assignment-rules.mdx +++ b/content/docs/automation/queues-and-assignment-rules.mdx @@ -146,7 +146,7 @@ In the Console both pickers list the declared queues and the organization's own ## A queue is not a messaging audience -A queue routes **records** to people. It is not an address for **messages**: a flow's `notify` audience grammar (`user:`, `role:`, `team:`, `owner_of:`) has no `queue:` selector, so "tell the queue that something new arrived" cannot be written as a queue. Address the people who staff it instead — the team, or the users holding the position the queue's members name — and remember that `owner_of:` on a parked record reaches nobody, because nobody owns it yet. +A queue routes **records** to people. It is not an address for **messages**: a flow's `notify` audience selectors (`user:`, `team:`, `owner_of:` and the rest) include no `queue:`, so "tell the queue that something new arrived" cannot be written as a queue. Address the people who staff it instead — the team, or the users holding the position the queue's members name — and remember that `owner_of:` on a parked record reaches nobody, because nobody owns it yet. ## Checklist From 58d26514356fd9bedb265fb2859bc39853d3746d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:40:42 +0000 Subject: [PATCH 6/7] =?UTF-8?q?fix(plugin-list):=20the=20In-a-queue=20filt?= =?UTF-8?q?er=20reads=20empty=20as=20the=20server=20does=20=E2=80=94=20nul?= =?UTF-8?q?l=20or=20''?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz --- ui/packages/plugin-list/src/ListView.tsx | 12 +++++++---- ...ListView.queueSittingFilter-25769.test.tsx | 21 +++++++++++++------ 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/ui/packages/plugin-list/src/ListView.tsx b/ui/packages/plugin-list/src/ListView.tsx index 9320dd998a1..4446c8aba4e 100644 --- a/ui/packages/plugin-list/src/ListView.tsx +++ b/ui/packages/plugin-list/src/ListView.tsx @@ -865,12 +865,16 @@ export type QueueSittingCondition = readonly [field: string, operator: string, v * own definition of a parked record, so the filter states exactly it and * nothing about who may see or claim the rows: what comes back is whatever the * server's read filter lets this user read (a queue's members read what sits - * in their queues). Module constants, so the fetch effect sees one identity - * per state (AGENTS.md #10). + * in their queues). "Empty" is the server's own reading on both columns — null + * OR `''` (the explain engine and the assignment seam both read it so) — so the + * conditions are `is_empty` / `is_not_empty` (lowered to `$empty`), not the + * null-only operators, which would list a row whose `queue` is `''` and miss + * one whose `owner_id` is `''`. Module constants, so the fetch effect sees one + * identity per state (AGENTS.md #10). */ export const QUEUE_SITTING_CONDITIONS: ReadonlyArray = [ - ['owner_id', 'is_null', true], - ['queue', 'is_not_null', true], + ['owner_id', 'is_empty', true], + ['queue', 'is_not_empty', true], ]; const NO_QUEUE_SITTING_CONDITIONS: ReadonlyArray = []; diff --git a/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx b/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx index ef83061eb0f..bbb29afd94c 100644 --- a/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx +++ b/ui/packages/plugin-list/src/__tests__/ListView.queueSittingFilter-25769.test.tsx @@ -15,9 +15,11 @@ * definition carries the platform-injected `queue` column (`system: true`); * an object without it, or with an author's own non-system `queue` field, * gets no toggle; - * - the request: pressing it AND-s exactly `owner_id is_null` and `queue - * is_not_null` into the fetch filter, and releasing it drops them. Nothing - * about who may see the rows: that stays the server's read filter. + * - the request: pressing it AND-s exactly `owner_id is_empty` and `queue + * is_not_empty` into the fetch filter, and releasing it drops them. Empty is + * the server's reading (null OR ''), so the null-only operators are not + * used. Nothing about who may see the rows: that stays the server's read + * filter. */ import * as React from 'react'; @@ -76,15 +78,22 @@ describe('objectHasOwnershipQueue — reads the served definition only', () => { describe('buildEffectiveFilter — the queue conditions are AND-ed like the rest', () => { const NONE = { logic: 'and', conditions: [] } as any; + it('states the platform\'s "sits in": owner empty and queue not empty, null or \'\' alike', () => { + expect(QUEUE_SITTING_CONDITIONS).toEqual([ + ['owner_id', 'is_empty', true], + ['queue', 'is_not_empty', true], + ]); + }); + it('alone, they are the whole filter', () => { expect(buildEffectiveFilter(undefined, NONE, [], QUEUE_SITTING_CONDITIONS)).toEqual([ - 'and', ['owner_id', 'is_null', true], ['queue', 'is_not_null', true], + 'and', ['owner_id', 'is_empty', true], ['queue', 'is_not_empty', true], ]); }); it('beside an authored filter, they narrow it', () => { expect(buildEffectiveFilter(['status', '=', 'open'], NONE, [], QUEUE_SITTING_CONDITIONS)).toEqual([ - 'and', ['status', '=', 'open'], ['owner_id', 'is_null', true], ['queue', 'is_not_null', true], + 'and', ['status', '=', 'open'], ['owner_id', 'is_empty', true], ['queue', 'is_not_empty', true], ]); }); @@ -108,7 +117,7 @@ describe('ListView "In a queue" toggle (ADR-0142 D3)', () => { fireEvent.click(toggle); await waitFor(() => expect(ds.find.mock.calls.length).toBeGreaterThan(before)); expect(screen.getByTestId('queue-sitting-filter').getAttribute('aria-pressed')).toBe('true'); - expect(lastFilter(ds)).toEqual(['and', ['owner_id', 'is_null', true], ['queue', 'is_not_null', true]]); + expect(lastFilter(ds)).toEqual(['and', ['owner_id', 'is_empty', true], ['queue', 'is_not_empty', true]]); const pressed = ds.find.mock.calls.length; fireEvent.click(screen.getByTestId('queue-sitting-filter')); From e3e83a00044f0403fe98735df1ae70dd48174192 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 13:58:49 +0000 Subject: [PATCH 7/7] =?UTF-8?q?test(plugin-form):=20the=20write=20census?= =?UTF-8?q?=20grades=20the=20queue=20claim=20=E2=80=94=20its=20helper,=20a?= =?UTF-8?q?nd=20the=20record=20page's=20Claim=20as=20answered=20by=20expla?= =?UTF-8?q?in,=20not=20an=20object=20grant?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufzfFCwcM21eGjVuqwRz --- .../plugin-form/src/affordanceGrantMap-12082.test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx index 1ba6b1f8e28..0777a23d66e 100644 --- a/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx +++ b/ui/packages/plugin-form/src/affordanceGrantMap-12082.test.tsx @@ -612,6 +612,7 @@ const WRITE_HELPERS: Readonly> = { emulateBatchTransaction: 'batch', 'recordDelete.run': 'delete', saveWithOcc: 'update', + claimQueuedRecord: 'update', }; type WriteSite = @@ -699,6 +700,14 @@ const WRITE_SITES: Record = { mapped(['feedReactionAdd'], RECORD_PAGE), 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > onClick :: delete': mapped(['recordDelete'], RECORD_PAGE), + // Claim on a record sitting in an ownership queue (objectstack ADR-0142 D4). + // No object grant answers a claim: the server admits it from the record's + // own sharing verdict (a queue member, an edit share, Modify All) on an + // unowned row, so no map row could. The page offers it only on the explain + // engine's record-scoped answer for a transfer of THIS record + // (`useQueueClaimOffered`, a `queue_claim` rule that `admits`). + 'packages/app-shell/src/views/RecordDetailView.tsx :: RecordDetailView > handleClaim :: update': + outOfFamily("a queue claim: admitted by the server from the row's own sharing verdict, not an object grant, and offered only on the explain engine's record-scoped `queue_claim: admits` answer for this record"), 'packages/app-shell/src/views/RelatedRecordActionsBridge.tsx :: RelatedRecordActionsBridge > value > resolve :: delete': mapped(['relatedRowDelete'], 'packages/app-shell/src/views/RelatedRecordActionsBridge.tsx'), // Setup's permission-set and position holders (objectui#12082, leg B's last @@ -797,6 +806,7 @@ const WRITE_SITES: Record = { // list's own object (with a `linkField`) is a create of the child, and a // re-parent of the picked record (without one) an update of the picked // object — the rows a related list's "+ New" and row Edit read. + 'packages/plugin-detail/src/queueClaim.ts :: claimQueuedRecord :: update': helper('claimQueuedRecord'), 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: create': mapped(['relatedNew'], RELATED_LIST), 'packages/plugin-detail/src/RelatedList.tsx :: RelatedList > handleAddRecords :: update':