diff --git a/.changeset/ban-dialog-assigned-work-handover.md b/.changeset/ban-dialog-assigned-work-handover.md new file mode 100644 index 00000000000..0b0c86edd27 --- /dev/null +++ b/.changeset/ban-dialog-assigned-work-handover.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +feat(app-shell): the ban dialog says how many records the user holds, and offers one optional hand-over + +Clause-②: no +Metadata-protocol: none + +Banning a user (the platform's `ban_user` action on `sys_user`) opens a dialog that now shows, above the ban reason, the records that person holds and lets the administrator hand them to someone else before confirming the ban. + +- **The counts.** Read from `security.assignedWork.count` of `@objectstack/client`, as the signed-in administrator: the total, how many the user owns (records whose `owner_id` names them) and how many they are assigned (every other user field), and one line per object and field. Objects the administrator cannot read are named as not counted; a count the server cut short says so; a multi-valued user field is counted and said not to be handed over here. The panel shows the server's answer as it came and computes no count of its own. +- **The hand-over.** Optional: pick an active person (banned users and the user being banned are not offered) and click Hand over. One click runs `security.assignedWork.transfer` once (the server moves at most 500 records per call), then reports how many moved, how many could not be moved with each record's reason, and how many still hold the user; when some remain, the button hands over the next batch. The counts are read again after each hand-over. A banned target is refused in the reader's language. +- **The ban never waits.** Confirm is still the ban and stays enabled while a count or a hand-over runs, or after either fails. +- **Where it is decided.** The dialog's host picks the panel from the platform action being run (`resolveParamDialogCompanion`); no authored metadata key selects it, and both param-dialog runtimes (`useConsoleActionRuntime` and the record page's) ask the same function. +- **Strings.** New `console.assignedWork.*` keys in all ten language packs. diff --git a/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts b/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts index c85a35424d0..31492e1c17d 100644 --- a/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts +++ b/ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts @@ -83,6 +83,7 @@ const OBJECTS_READERS: Record = { 'packages/app-shell/src/hooks/useNavTargetIcon.ts': { kind: 'byName', why: "an object nav entry with no icon, its object's icon by name, as the sidebar reads that entry's label (objectstack#30126)" }, 'packages/app-shell/src/hooks/useRecentItemLabel.ts': { kind: 'byName', why: "a recent object or view entry's object, by name (objectstack#29121)" }, 'packages/app-shell/src/layout/ActivityObjectLabel.tsx': { kind: 'byName', why: "the inbox's and the home rail's activity rows, each row's object by name (objectstack#29121)" }, + 'packages/app-shell/src/views/UserAssignedWorkPanel.tsx': { kind: 'byName', why: "the ban dialog's assigned-work panel, each counted object by name (objectstack#30205)" }, 'packages/app-shell/src/chrome/CommandPalette.tsx': { kind: 'off', why: "the Studio scope's object list, read while the palette lists Studio entries" }, 'packages/app-shell/src/console/home/HomeLayout.tsx': { kind: 'scope', diff --git a/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.banCompanion-30205.test.tsx b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.banCompanion-30205.test.tsx new file mode 100644 index 00000000000..28dfc90d1af --- /dev/null +++ b/ui/packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime.banCompanion-30205.test.tsx @@ -0,0 +1,123 @@ +/** + * 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. + */ + +/** + * `useConsoleActionRuntime`'s param-collection handler hands the dialog the + * host-composed companion `resolveParamDialogCompanion` picks + * (objectstack#30205): the ban dialog carries the user's assigned-work panel + * for the row it runs on, and any other action carries none. + * `RecordDetailView`'s handler, the second reader of the same seam, has its + * own copy of these pins. + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, act } from '@testing-library/react'; +import React from 'react'; + +vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() })); +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +/** The bundle, keyed `KEY_OBJECT|ACTION` (`global` for no key object). */ +let bundle: Record = {}; +const asked: Array<{ objectName: string | undefined; actionName: string | undefined; fallback: unknown }> = []; +const LANGUAGE = 'zh-CN'; + +// Partial mock: `@object-ui/components` reaches for `createSafeTranslation` at +// import time, so a total double breaks the module graph. +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useObjectLabel: () => ({ + fieldLabel: (_o: any, _n: any, l: any) => l, + fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l, + actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback, + actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback, + actionDescription: (_o: any, _a: any, fallback: any) => fallback, + actionSubmitText: (_o: any, _a: any, fallback: any) => fallback, + actionCancelText: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => { + asked.push({ objectName, actionName, fallback }); + return bundle[`${objectName ?? 'global'}|${actionName}`] ?? (fallback || undefined); + }, + }), + useObjectTranslation: () => ({ + t: (key: string, options?: any) => String(options?.defaultValue ?? key), + language: LANGUAGE, + }), +})); + +vi.mock('../useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +let paramDialogState: any = null; +vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('../../views/ActionParamDialog', () => ({ + ActionParamDialog: ({ state }: any) => { + if (state?.open) paramDialogState = state; + return null; + }, +})); +vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('sonner', () => { + const fn: any = vi.fn(); + fn.error = vi.fn(); + fn.success = vi.fn(); + return { toast: fn }; +}); + +import { useConsoleActionRuntime } from '../useConsoleActionRuntime'; + +const PARAMS = [{ name: 'subject', label: 'Subject', type: 'text' }] as any[]; + +function Harness({ onReady }: { onReady: (fn: any) => void }) { + const runtime = useConsoleActionRuntime({ dataSource: {}, objects: [] }); + const ready = React.useRef(false); + if (!ready.current) { + ready.current = true; + onReady(runtime.actionProviderProps.onParamCollection); + } + return <>{runtime.dialogs}; +} + +async function collectParams(dispatch: Record) { + paramDialogState = null; + let collect: any; + render( { collect = fn; }} />); + await act(async () => { + void collect(PARAMS, dispatch as any); + await Promise.resolve(); + }); + expect(paramDialogState).toBeTruthy(); +} + +beforeEach(() => { + bundle = {}; + asked.length = 0; + paramDialogState = null; +}); + +describe('useConsoleActionRuntime — the ban dialog\'s companion (objectstack#30205)', () => { + it('hands the ban dialog the assigned-work panel for the row it runs on', async () => { + await collectParams({ name: 'ban_user', label: 'Ban User', objectName: 'sys_user', params: { _rowRecord: { id: 'u_lee', name: 'Lee' } } }); + expect(paramDialogState.companion).toEqual({ kind: 'user-assigned-work', userId: 'u_lee' }); + }); + + it('CONTROL: another sys_user action carries no companion', async () => { + await collectParams({ name: 'unban_user', label: 'Unban User', objectName: 'sys_user', params: { _rowRecord: { id: 'u_lee' } } }); + expect(paramDialogState.companion).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx index 345b08a322f..9fb1badeaab 100644 --- a/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx +++ b/ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx @@ -44,6 +44,7 @@ import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, res import { useActionModal } from './useActionModal.js'; import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog.js'; import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog.js'; +import { resolveParamDialogCompanion } from '../views/actionDialogCompanion.js'; import { ActionResultDialog, type ResultDialogState } from '../views/ActionResultDialog.js'; import { FlowRunner, type ScreenFlowState, type ScreenSpec } from '../views/FlowRunner.js'; import { FlowRefusalNotice, type FlowRefusalState } from '../views/FlowRefusalNotice.js'; @@ -370,6 +371,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons // The runner's `run`, so Confirm runs the action from inside the // dialog and a refusal keeps what the user typed (objectui#13161). run, + // The host-composed panel above the params (objectstack#30205). + companion: resolveParamDialogCompanion(objForI18n, action?.name, row), }); })(); }); }, [objectName, objectDef, objects, metadata, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionSubmitText, actionCancelText, language]); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.banAssignedWork-30205.test.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.banAssignedWork-30205.test.tsx new file mode 100644 index 00000000000..5c574f10d20 --- /dev/null +++ b/ui/packages/app-shell/src/views/ActionParamDialog.banAssignedWork-30205.test.tsx @@ -0,0 +1,290 @@ +/** + * 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 ban dialog's companion (objectstack#30205): the user's open work and one + * optional hand-over, drawn by `ActionParamDialog` above the params when its + * state carries `companion: { kind: 'user-assigned-work' }`. + * + * The client's `security.assignedWork.count` / `.transfer` are doubles that + * record what they are asked. `t()` answers its key followed by its options as + * JSON, so each assertion names the sentence AND the numbers in it without + * pinning any language's wording. The user picker is a plain input standing in + * for the lazy `user` widget. + */ +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor, within, act } from '@testing-library/react'; +import type { ActionParamDef } from '@object-ui/core'; + +// The client bundle's own words for some objects, which differ from what the +// server serves: the panel must draw the served label (objectstack#30486) and +// ask the bundle only for an object with none. +const CATALOG: Record = { + showcase_project: 'Catalogue Project', + hr_leave: 'Catalogue Leave', +}; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + useObjectTranslation: () => ({ + t: (key: string, options?: Record) => (options ? `${key} ${JSON.stringify(options)}` : key), + language: 'en', + }), + useObjectLabel: () => ({ + objectLabel: (def: { name: string; label: string }) => CATALOG[def.name] ?? def.label, + fieldLabel: (_o: string, _f: string, fallback: string) => fallback, + }), +})); + +vi.mock('@object-ui/fields', async (importOriginal) => ({ + ...(await importOriginal()), + getLazyFieldWidget: () => ({ id, value, onChange }: any) => ( + onChange(e.target.value)} /> + ), +})); + +import { AdapterCtx, MetadataCtx } from '@object-ui/react'; +import { ActionParamDialog } from './ActionParamDialog'; +import { splitAssignedWork } from './userAssignedWork'; + +const USER_ID = 'u_lee'; +const PARAMS: ActionParamDef[] = [{ name: 'banReason', label: 'Ban Reason', type: 'text' }]; + +const OBJECTS = [ + { name: 'showcase_project', label: 'Project', fields: { owner_id: { label: 'Owner' } } }, + { name: 'showcase_field_zoo', label: 'Field Zoo', fields: { f_user: { label: 'User' }, f_users: { label: 'Users' } } }, + { name: 'hr_review', label: 'Review', fields: {} }, +]; + +const SUMMARY = { + userId: USER_ID, + objects: [ + { object: 'showcase_project', field: 'owner_id', count: 3, transferable: true }, + { object: 'showcase_field_zoo', field: 'f_user', count: 2, transferable: true }, + { object: 'showcase_field_zoo', field: 'f_users', count: 2, transferable: false }, + ], + total: 7, + truncated: false, + unreadable: [] as string[], +}; + +let count: ReturnType; +let transfer: ReturnType; + +function mount(resolve = vi.fn(), metadata: unknown = { objects: OBJECTS }) { + const adapter = { getClient: () => ({ security: { assignedWork: { count, transfer } } }) } as any; + render( + + + {}} + /> + + , + ); + return resolve; +} + +const confirm = () => screen.getByRole('button', { name: 'actionDialog.confirm' }); + +async function pickTarget(id: string) { + const input = await screen.findByTestId('widget-assigned_work_target'); + fireEvent.change(input, { target: { value: id } }); +} + +beforeEach(() => { + count = vi.fn(async () => SUMMARY); + transfer = vi.fn(); +}); + +describe('the ban dialog says how many records the user holds (objectstack#30205)', () => { + it('counts the dialog\'s user once and shows owned (owner_id) apart from assigned', async () => { + mount(); + const summary = await screen.findByTestId('user-assigned-work-summary'); + expect(count).toHaveBeenCalledTimes(1); + expect(count).toHaveBeenCalledWith(USER_ID); + expect(within(summary).getByText('console.assignedWork.summary {"total":7,"owned":3,"assigned":4}')).toBeTruthy(); + const ownerRow = screen.getByTestId('user-assigned-work-row-showcase_project-owner_id'); + expect(ownerRow.textContent).toContain('console.assignedWork.row {"object":"Project","field":"Owner"}'); + expect(ownerRow.textContent).toContain('3'); + // A multi-valued field is counted and said not to be handed over here. + const multi = screen.getByTestId('user-assigned-work-row-showcase_field_zoo-f_users'); + expect(multi.textContent).toContain('console.assignedWork.notTransferable'); + expect(screen.getByTestId('user-assigned-work-row-showcase_field_zoo-f_user').textContent) + .not.toContain('console.assignedWork.notTransferable'); + }); + + it('owned is the owner_id rows only; every other user field is assigned', () => { + expect(splitAssignedWork(SUMMARY)).toEqual({ owned: 3, assigned: 4 }); + expect(splitAssignedWork({ ...SUMMARY, objects: [{ object: 'a', field: 'owner', count: 5, transferable: true }], total: 5 })) + .toEqual({ owned: 0, assigned: 5 }); + }); + + it('says when the count was cut short and names the objects it could not read', async () => { + count = vi.fn(async () => ({ ...SUMMARY, truncated: true, unreadable: ['hr_review', 'no_such_def'] })); + mount(); + expect(await screen.findByTestId('user-assigned-work-truncated')).toBeTruthy(); + expect(screen.getByTestId('user-assigned-work-unreadable').textContent) + .toBe('console.assignedWork.unreadable {"objects":"Review, no_such_def"}'); + }); + + it('names an object by the label the server served, not the bundle\'s; the bundle only when none is served', async () => { + count = vi.fn(async () => ({ ...SUMMARY, unreadable: ['hr_leave', 'no_such_def'] })); + mount(); + const ownerRow = await screen.findByTestId('user-assigned-work-row-showcase_project-owner_id'); + // Served "Project" differs from the bundle's "Catalogue Project": served wins. + expect(ownerRow.textContent).toContain('console.assignedWork.row {"object":"Project","field":"Owner"}'); + expect(ownerRow.textContent).not.toContain('Catalogue Project'); + // No definition served: the bundle names it, else the object's name. + expect(screen.getByTestId('user-assigned-work-unreadable').textContent) + .toBe('console.assignedWork.unreadable {"objects":"Catalogue Leave, no_such_def"}'); + }); + + it('reads the counted objects by name, never the whole object registry', async () => { + let wholeListRead = false; + const readObjectScope = vi.fn(({ names }: { names: string[] }) => ({ + objects: OBJECTS.filter((o) => names.includes(o.name)), + })); + count = vi.fn(async () => ({ ...SUMMARY, unreadable: ['hr_review'] })); + mount(vi.fn(), { + getTypeStatus: () => 'idle', + readObjectScope, + get objects() { wholeListRead = true; return OBJECTS; }, + }); + const ownerRow = await screen.findByTestId('user-assigned-work-row-showcase_project-owner_id'); + expect(ownerRow.textContent).toContain('console.assignedWork.row {"object":"Project","field":"Owner"}'); + expect(screen.getByTestId('user-assigned-work-unreadable').textContent) + .toBe('console.assignedWork.unreadable {"objects":"Review"}'); + expect(readObjectScope).toHaveBeenLastCalledWith({ + names: ['hr_review', 'showcase_field_zoo', 'showcase_project'], + views: false, + }); + expect(wholeListRead).toBe(false); + }); + + it('a user who holds nothing readable: says so and offers no hand-over', async () => { + count = vi.fn(async () => ({ userId: USER_ID, objects: [], total: 0, truncated: false, unreadable: [] })); + mount(); + expect(await screen.findByText('console.assignedWork.none')).toBeTruthy(); + expect(screen.queryByTestId('user-assigned-work-handover')).toBeNull(); + }); + + it('a refused count is shown in the panel and the ban stays confirmable', async () => { + count = vi.fn(async () => { throw new Error('Forbidden'); }); + const resolve = mount(); + expect((await screen.findByTestId('user-assigned-work-count-failed')).textContent) + .toBe('console.assignedWork.countFailed {"message":"Forbidden"}'); + expect((confirm() as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(confirm()); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ banReason: undefined })); + }); +}); + +describe('the ban dialog offers one optional hand-over (objectstack#30205)', () => { + it('the hand-over button waits for a person to be picked', async () => { + mount(); + await screen.findByTestId('user-assigned-work-handover'); + const button = screen.getByTestId('user-assigned-work-transfer') as HTMLButtonElement; + expect(button.disabled).toBe(true); + await pickTarget('u_kim'); + expect(button.disabled).toBe(false); + }); + + it('a complete hand-over: reports what moved and reads the counts again', async () => { + transfer = vi.fn(async () => ({ + fromUserId: USER_ID, toUserId: 'u_kim', updated: 5, failed: 0, remaining: 0, + objects: [{ object: 'showcase_project', field: 'owner_id', updated: 3, failed: 0, failures: [] }], + })); + mount(); + await pickTarget('u_kim'); + fireEvent.click(screen.getByTestId('user-assigned-work-transfer')); + const report = await screen.findByTestId('user-assigned-work-report'); + expect(transfer).toHaveBeenCalledWith(USER_ID, { toUserId: 'u_kim' }); + expect(report.textContent).toContain('console.assignedWork.report {"updated":5,"failed":0,"remaining":0}'); + await waitFor(() => expect(count).toHaveBeenCalledTimes(2)); + expect(screen.getByTestId('user-assigned-work-transfer').textContent).toBe('console.assignedWork.handover'); + }); + + it('a partial hand-over: names each failed record with its reason and offers the next batch', async () => { + transfer = vi.fn(async () => ({ + fromUserId: USER_ID, toUserId: 'u_kim', updated: 2, failed: 1, remaining: 1, + objects: [{ + object: 'showcase_project', field: 'owner_id', updated: 2, failed: 1, + failures: [{ id: 'p3', code: 'RECORD_LOCKED', message: 'The record is locked by an approval' }], + }], + })); + mount(); + await pickTarget('u_kim'); + fireEvent.click(screen.getByTestId('user-assigned-work-transfer')); + const report = await screen.findByTestId('user-assigned-work-report'); + expect(report.textContent).toContain('console.assignedWork.report {"updated":2,"failed":1,"remaining":1}'); + expect(screen.getAllByTestId('user-assigned-work-failure').map((li) => li.textContent)).toEqual([ + 'console.assignedWork.failure {"object":"Project","id":"p3","message":"The record is locked by an approval"}', + ]); + // Something remains: the next click hands over the next batch. + expect(screen.getByTestId('user-assigned-work-transfer').textContent).toBe('console.assignedWork.handoverNext'); + fireEvent.click(screen.getByTestId('user-assigned-work-transfer')); + await waitFor(() => expect(transfer).toHaveBeenCalledTimes(2)); + }); + + it('a hand-over to a banned person is refused in the user\'s language, not the server\'s', async () => { + transfer = vi.fn(async () => { + throw Object.assign(new Error('User "u_kim" is banned and cannot be assigned new records; pick an active user'), { + code: 'VALIDATION_ERROR', + fields: [{ field: 'toUserId', code: 'reference_user_banned' }], + }); + }); + mount(); + await pickTarget('u_kim'); + fireEvent.click(screen.getByTestId('user-assigned-work-transfer')); + expect((await screen.findByTestId('user-assigned-work-transfer-refused')).textContent).toBe('console.assignedWork.targetBanned'); + expect(screen.queryByTestId('user-assigned-work-report')).toBeNull(); + }); + + it('any other refused hand-over shows the server\'s reason', async () => { + transfer = vi.fn(async () => { throw new Error('No user has id "u_kim"'); }); + mount(); + await pickTarget('u_kim'); + fireEvent.click(screen.getByTestId('user-assigned-work-transfer')); + expect((await screen.findByTestId('user-assigned-work-transfer-refused')).textContent) + .toBe('console.assignedWork.transferRefused {"message":"No user has id \\"u_kim\\""}'); + }); + + it('the ban never waits for the hand-over: Confirm stays clickable while one runs, and bans', async () => { + let finish!: (v: unknown) => void; + transfer = vi.fn(() => new Promise((r) => { finish = r; })); + const resolve = mount(); + await pickTarget('u_kim'); + fireEvent.click(screen.getByTestId('user-assigned-work-transfer')); + await waitFor(() => expect(screen.getByTestId('user-assigned-work-transfer').getAttribute('aria-busy')).toBe('true')); + expect((confirm() as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(confirm()); + await waitFor(() => expect(resolve).toHaveBeenCalledWith({ banReason: undefined })); + await act(async () => { + finish({ fromUserId: USER_ID, toUserId: 'u_kim', updated: 0, failed: 0, remaining: 0, objects: [] }); + }); + }); + + it('the panel is not drawn on a dialog without the companion', async () => { + render( + {}} />, + ); + await screen.findByRole('button', { name: 'actionDialog.confirm' }); + expect(screen.queryByTestId('user-assigned-work')).toBeNull(); + expect(count).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/app-shell/src/views/ActionParamDialog.tsx b/ui/packages/app-shell/src/views/ActionParamDialog.tsx index bf14c95b142..9c5346fb661 100644 --- a/ui/packages/app-shell/src/views/ActionParamDialog.tsx +++ b/ui/packages/app-shell/src/views/ActionParamDialog.tsx @@ -55,6 +55,16 @@ * every entry is marked, the refusal box is not drawn, since it would only * repeat the marks in English; any entry the dialog cannot mark (another * code, a param it does not draw) keeps the box, with the server's text. + * + * ## A host-composed companion above the params (objectstack#30205) + * + * `ParamDialogState.companion` draws a panel at the top of the scrolling + * region. The host picks it from the platform action being run + * (`resolveParamDialogCompanion` in `actionDialogCompanion.ts`); no authored + * key selects it. Today the one companion is the ban dialog's account of the + * user's open work and its optional hand-over (`UserAssignedWorkPanel`). The + * companion never gates Confirm: Confirm runs the action whatever the panel + * is doing. */ import { Suspense, useState, useEffect, useMemo } from 'react'; @@ -96,6 +106,8 @@ import { getLazyFieldWidget, fileIdOf, buildValidationRules, resolveParamWidgetT import { useInitialFieldFocus } from '@object-ui/plugin-form'; import { paramToField, paramDegradesWithoutTarget } from '../utils/paramToField.js'; import { useActionDialogReturnFocus } from './actionDialogReturnFocus.js'; +import type { ParamDialogCompanion } from './actionDialogCompanion.js'; +import { UserAssignedWorkPanel } from './UserAssignedWorkPanel.js'; export interface ParamDialogState { open: boolean; @@ -138,6 +150,13 @@ export interface ParamDialogState { * values on failure. Absent, Confirm resolves and closes at once. */ run?: Parameters[3]; + /** + * A host-composed panel drawn above the params — decided by the host from + * the platform action being run (`resolveParamDialogCompanion`), never read + * from authored metadata. It informs and offers an optional step of its + * own; Confirm stays the action and never waits on it (objectstack#30205). + */ + companion?: ParamDialogCompanion; } /** @@ -699,6 +718,11 @@ export function ActionParamDialog({ state, onOpenChange }: ActionParamDialogProp className={cn('-mx-6 grid min-h-0 flex-1 gap-4 overflow-y-auto px-6 py-4', isLongList && 'sm:grid-cols-2')} data-testid="action-param-body" > + {state.companion?.kind === 'user-assigned-work' && ( + // Keyed by the user: another user is another subject, never a + // stale count carried over. + + )} {visibleParams.map((rawParam) => { const param = { ...rawParam, diff --git a/ui/packages/app-shell/src/views/RecordDetailView.banCompanion-30205.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.banCompanion-30205.test.tsx new file mode 100644 index 00000000000..33769d43a66 --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.banCompanion-30205.test.tsx @@ -0,0 +1,227 @@ +/** + * `RecordDetailView`'s param-collection handler — the SECOND reader of the + * param dialog's seam — hands the dialog the companion + * `resolveParamDialogCompanion` picks, as `useConsoleActionRuntime`'s does + * (objectstack#30205). The record header's `action:bar` stamps the page's + * record into `params._rowRecord`, which is the row the companion reads. + * The harness is the one `RecordDetailView.paramCancelText-28879.test.tsx` + * mounts. + */ + + +import * as React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, waitFor, act, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u1', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +/** + * Capture the state the param dialog is handed — it IS the assertion subject. + * The sibling dialogs are orthogonal chrome, stubbed like the other + * RecordDetailView suites do. + */ +let paramDialogState: any = null; +vi.mock('./ActionParamDialog', () => ({ + ActionParamDialog: ({ state }: any) => { + if (state?.open) paramDialogState = state; + return null; + }, +})); +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +// Importing the client modal transport for real drags in and the +// whole plugin-form graph — same posture as the modal-dispatch suite. +vi.mock('../hooks/useActionModal', () => ({ + useActionModal: () => ({ + modalHandler: vi.fn(async () => ({ success: true })), + modalElement: null, + closeModal: () => {}, + resolveModalTarget: vi.fn(async () => null), + }), +})); + +vi.mock('../utils/consoleServerAction', () => ({ + createConsoleServerActionHandler: () => vi.fn(async () => ({ success: true })), +})); + +/** + * Capture every 's props while keeping the real provider. The + * record page's own provider is the one whose CONTEXT carries this record, so + * the selector below picks it even if a nested surface mounts one of its own. + * `useObjectLabel` stays real except `actionCancelText`, and + * `useObjectTranslation` stays real except its `language`, so an inline locale + * map has a non-default language to collapse to. + */ +const captured: Array<{ onParamCollection: any; context: any }> = []; +/** The bundle, keyed `KEY_OBJECT|ACTION` (`global` for no key object). */ +let bundle: Record = {}; +const asked: Array<{ objectName: string | undefined; actionName: string | undefined; fallback: unknown }> = []; +const LANGUAGE = 'zh-CN'; +vi.mock('@object-ui/react', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ActionProvider: (props: any) => { + captured.push({ onParamCollection: props.onParamCollection, context: props.context }); + return React.createElement(actual.ActionProvider as any, props); + }, + SchemaRenderer: () => null, + // The REAL resolvers, with `actionCancelText` standing in for the bundle + // so this file pins what the handler asks and passes on; the bundle node + // lookup is `@object-ui/i18n`'s own pin. + useObjectLabel: () => ({ + ...actual.useObjectLabel(), + actionCancelText: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => { + asked.push({ objectName, actionName, fallback }); + return bundle[`${objectName ?? 'global'}|${actionName}`] ?? (fallback || undefined); + }, + }), + useObjectTranslation: () => ({ ...actual.useObjectTranslation(), language: LANGUAGE }), + }; +}); + +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; + +const OBJECT_NAME = 'sys_user'; +const RECORD_ID = 'u_lee'; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'User', + fields: { + id: { type: 'text', label: 'Id' }, + name: { type: 'text', label: 'Name' }, + }, + }, +]; + +const METADATA = { + objects: OBJECTS, + pages: [], + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => [], + getItem: async () => null, + getItemsByType: () => [], +} as any; + +const PARAMS = [{ name: 'subject', label: 'Subject', type: 'text' }] as any[]; + +function makeDataSource() { + return { + find: vi.fn(async () => ({ data: [] })), + findOne: vi.fn(async () => ({ id: RECORD_ID, name: 'Intro call' })), + create: vi.fn(async () => ({})), + update: vi.fn(async () => ({})), + delete: vi.fn(async () => ({})), + } as any; +} + +/** The record page's OWN `onParamCollection` — the provider given this record. */ +function recordPageParamCollection() { + return [...captured] + .reverse() + .find((c) => c.onParamCollection && c.context?.record?.id === RECORD_ID)?.onParamCollection; +} + +/** + * Mount the view, then drive its `onParamCollection` the way `DeclaredActionsBar` + * does and read back the state the dialog received. The promise stays pending — + * that is the real shape too: nothing is POSTed until the dialog's own Confirm. + */ +async function collectParams(dispatch: Record, interpolate?: (text: string) => string) { + render( + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + , + ); + await waitFor(() => expect(recordPageParamCollection()).toBeTruthy()); + const collect = recordPageParamCollection()!; + paramDialogState = null; + await act(async () => { + void collect(PARAMS, dispatch as any, interpolate); + await Promise.resolve(); + }); +} + +beforeEach(() => { + cleanup(); + captured.length = 0; + paramDialogState = null; + bundle = {}; + asked.length = 0; + // Unrelated chrome on this view (approvals, favourites, …) reaches for the + // platform API; in jsdom that is a real socket. Answer it locally so the only + // asynchrony left is the record load the capture waits on. + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ), + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('RecordDetailView — the ban dialog\'s companion (objectstack#30205)', () => { + it('hands the ban dialog the assigned-work panel for the page\'s user', async () => { + await collectParams({ name: 'ban_user', label: 'Ban User', objectName: OBJECT_NAME, params: { _rowRecord: { id: RECORD_ID } } }); + expect(paramDialogState.companion).toEqual({ kind: 'user-assigned-work', userId: RECORD_ID }); + }); + + it('CONTROL: another sys_user action carries no companion', async () => { + await collectParams({ name: 'unban_user', label: 'Unban User', objectName: OBJECT_NAME, params: { _rowRecord: { id: RECORD_ID } } }); + expect(paramDialogState.companion).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx index 309f3f45738..5b90c75f285 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.paramRuntimeParity-6431.test.tsx @@ -223,6 +223,10 @@ const ACTION = { const DIALOG_READS = [ // The dismiss button's label (`cancelText`), resolved by each runtime. 'cancelText', + // The host-composed panel above the params (objectstack#30205). This suite's + // action carries none, so it is written `undefined`; the key is on both + // runtimes' state, which is what parity reads. + 'companion', 'description', // The runner's template filler for each param's `helpText` (objectui#12967). // This suite's handlers are called without one, so it is written `undefined`; diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index 0c251c23f34..f3c0c6d4703 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -30,6 +30,7 @@ import { pruneUnregisteredRecordPageObjects, recordPageChildObjects } from './re import { stripDiscussionNodes, hasExplicitAttachments, hasExplicitApprovals } from '../utils/pageSchemaIntrospect.js'; import { ActionConfirmDialog, type ConfirmDialogState } from './ActionConfirmDialog.js'; import { ActionParamDialog, type ParamDialogState } from './ActionParamDialog.js'; +import { resolveParamDialogCompanion } from './actionDialogCompanion.js'; import { ActionResultDialog, type ResultDialogState } from './ActionResultDialog.js'; import { FlowRunner, type ScreenFlowState, type ScreenSpec } from './FlowRunner.js'; import { FlowRefusalNotice, type FlowRefusalState } from './FlowRefusalNotice.js'; @@ -1596,6 +1597,9 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri // The runner's `run`, as `useConsoleActionRuntime` forwards it: a // refused run keeps what the user typed (objectui#13161). run, + // The host-composed panel above the params, picked as + // `useConsoleActionRuntime` picks it (objectstack#30205). + companion: resolveParamDialogCompanion(objForI18n, action?.name, row), }); }); }, [objectName, objectDef, objects, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionSubmitText, actionCancelText, language]); diff --git a/ui/packages/app-shell/src/views/UserAssignedWorkPanel.tsx b/ui/packages/app-shell/src/views/UserAssignedWorkPanel.tsx new file mode 100644 index 00000000000..0676564680b --- /dev/null +++ b/ui/packages/app-shell/src/views/UserAssignedWorkPanel.tsx @@ -0,0 +1,316 @@ +/** + * UserAssignedWorkPanel — a user's open work, shown in the ban dialog, with one + * optional bulk hand-over (objectstack#30205). + * + * Banning a user signs them out, but the records they own and the records + * assigned to them stay where they were. This panel tells the administrator + * how many there are and lets them hand those records to someone else, in the + * same dialog, before they confirm the ban. + * + * ## What it reads, and as whom + * + * `security.assignedWork.count` and `.transfer` of `@objectstack/client`, as + * the signed-in administrator. The counts are the records the administrator + * can read; an object they cannot read is named, never counted as zero; a + * count the server cut short says so. Each moved record is an ordinary update + * under the administrator's permissions, so a record they may not change is + * reported as a failure with the server's reason. The panel shows the server's + * answer as it came: it computes no count of its own. + * + * "Owned" is the records whose `owner_id` names the user; every other user + * field is "assigned". A multi-valued user field is counted but cannot be + * handed over here (the server answers `transferable: false`). + * + * ## Never in the ban's way + * + * The panel is optional and runs nothing on its own: the dialog's Confirm is + * the ban and stays enabled whatever the panel is doing, and a failed count or + * hand-over is shown here without blocking it. One hand-over moves at most the + * server's batch (500 records) and reports what moved, what failed and how + * many still hold the user; a further batch is the administrator's click, and + * the counts are read again after each one. + */ + +import { Suspense, useCallback, useEffect, useMemo, useState } from 'react'; +import { Button, Label } from '@object-ui/components'; +import { Loader2 } from 'lucide-react'; +import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n'; +import { useAdapter, useMetadata } from '@object-ui/react'; +import { getLazyFieldWidget } from '@object-ui/fields'; +import type { ObjectStackAdapter } from '@object-ui/data-objectstack'; +import { servedObjectLabel } from '@object-ui/core'; +import { readObjectsByName } from '../hooks/useObjectPresence.js'; +import { splitAssignedWork, type AssignedWorkSummary, type AssignedWorkTransferReport } from './userAssignedWork.js'; + +/** The `user` field widget the hand-over picks its person with (lazy until drawn). */ +const UserWidget = getLazyFieldWidget('user'); + +/** The id of the picked person, whatever shape the user picker hands back. */ +function pickedUserId(value: unknown): string | undefined { + if (typeof value === 'string') return value || undefined; + if (typeof value === 'number') return String(value); + const id = (value as { id?: unknown } | null | undefined)?.id; + return typeof id === 'string' && id ? id : typeof id === 'number' ? String(id) : undefined; +} + +/** A refused call's words: the client throws an `Error` carrying the server's message. */ +function failureText(err: unknown): string { + const message = (err as { message?: unknown } | null | undefined)?.message; + return typeof message === 'string' && message ? message : String(err); +} + +/** The slice of an object definition the panel labels rows with. */ +type ObjectDefLike = { name?: unknown; label?: unknown; fields?: Record }; + +type CountState = + | { phase: 'loading' } + | { phase: 'failed'; message: string | null } + | { phase: 'ready'; summary: AssignedWorkSummary }; + +type TransferState = + | { phase: 'idle' } + | { phase: 'running' } + | { phase: 'refused'; message: string; banned: boolean } + | { phase: 'done'; report: AssignedWorkTransferReport }; + +export interface UserAssignedWorkPanelProps { + /** The user whose work is counted and handed over. */ + userId: string; +} + +export function UserAssignedWorkPanel({ userId }: UserAssignedWorkPanelProps) { + const { t, language } = useObjectTranslation(); + const { objectLabel, fieldLabel } = useObjectLabel(); + const adapter = useAdapter() as ObjectStackAdapter | null; + const metadata = useMetadata(); + const [count, setCount] = useState({ phase: 'loading' }); + const [transfer, setTransfer] = useState({ phase: 'idle' }); + const [target, setTarget] = useState(null); + const [generation, setGeneration] = useState(0); + + useEffect(() => { + let live = true; + void (async () => { + try { + if (!adapter) { + if (live) setCount({ phase: 'failed', message: null }); + return; + } + const summary = await adapter.getClient().security.assignedWork.count(userId); + if (live) setCount({ phase: 'ready', summary }); + } catch (err) { + if (live) setCount({ phase: 'failed', message: failureText(err) }); + } + })(); + return () => { live = false; }; + }, [adapter, userId, generation]); + + // The definitions of the objects the answers name, read by name: opening + // the ban dialog never pulls the whole object registry back. + const namesKey = useMemo(() => { + const names = new Set(); + if (count.phase === 'ready') { + for (const row of count.summary.objects) names.add(row.object); + for (const name of count.summary.unreadable) names.add(name); + } + if (transfer.phase === 'done') for (const row of transfer.report.objects) names.add(row.object); + return JSON.stringify([...names].sort()); + }, [count, transfer]); + const namedObjects = useMemo(() => JSON.parse(namesKey) as string[], [namesKey]); + const objectDefs = readObjectsByName(metadata, namedObjects) as ReadonlyArray; + const objectDefOf = useCallback( + (name: string) => objectDefs.find((o) => o?.name === name) ?? undefined, + [objectDefs], + ); + // An object's name follows the served-object-label rule (objectstack#30486): + // the label the server served is drawn as given, and the client bundle is + // asked only when none is served, so a renamed object shows its new name. + const objectText = useCallback((name: string) => { + const def = objectDefOf(name); + return servedObjectLabel({ name, label: def?.label }, language, objectLabel, name); + }, [objectDefOf, language, objectLabel]); + const fieldText = useCallback((object: string, field: string) => { + const declared = objectDefOf(object)?.fields?.[field]?.label; + return fieldLabel(object, field, declared || field); + }, [objectDefOf, fieldLabel]); + + // The candidate list leaves out the person being banned; the user picker + // itself leaves out every banned user. + const pickerField = useMemo(() => ({ + name: 'assigned_work_target', + type: 'user', + label: t('console.assignedWork.handoverTo'), + lookupFilters: [{ field: 'id', operator: 'ne', value: userId }], + }), [t, userId]); + + const targetId = pickedUserId(target); + + const runTransfer = async () => { + if (!adapter || !targetId) return; + setTransfer({ phase: 'running' }); + try { + const report = await adapter.getClient().security.assignedWork.transfer(userId, { toUserId: targetId }); + setTransfer({ phase: 'done', report }); + // Read the counts again: what moved no longer holds the user. The + // previous answer stays drawn until the new one arrives. + setGeneration((g) => g + 1); + } catch (err) { + const fields = (err as { fields?: Array<{ code?: unknown }> } | null)?.fields; + const banned = Array.isArray(fields) && fields.some((f) => f?.code === 'reference_user_banned'); + setTransfer({ phase: 'refused', message: failureText(err), banned }); + } + }; + + return ( +
+

{t('console.assignedWork.title')}

+ + {count.phase === 'loading' && ( +

+

+ )} + + {count.phase === 'failed' && ( +

+ {count.message === null + ? t('console.assignedWork.noClient') + : t('console.assignedWork.countFailed', { message: count.message })} +

+ )} + + {count.phase === 'ready' && ( + + )} + + {count.phase === 'ready' && count.summary.objects.some((o) => o.transferable) && ( +
+ + +

{t('console.assignedWork.handoverHint')}

+
+ +
+
+ )} + + {transfer.phase === 'refused' && ( +

+ {transfer.banned + ? t('console.assignedWork.targetBanned') + : t('console.assignedWork.transferRefused', { message: transfer.message })} +

+ )} + + {transfer.phase === 'done' && ( + + )} +
+ ); +} + +function AssignedWorkSummaryView({ + summary, + objectText, + fieldText, +}: { + summary: AssignedWorkSummary; + objectText: (name: string) => string; + fieldText: (object: string, field: string) => string; +}) { + const { t } = useObjectTranslation(); + const { owned, assigned } = splitAssignedWork(summary); + return ( +
+

+ {summary.total > 0 + ? t('console.assignedWork.summary', { total: summary.total, owned, assigned }) + : t('console.assignedWork.none')} +

+ {summary.objects.length > 0 && ( +
    + {summary.objects.map((row) => ( +
  • + + {t('console.assignedWork.row', { object: objectText(row.object), field: fieldText(row.object, row.field) })} + {!row.transferable && ( + {t('console.assignedWork.notTransferable')} + )} + + {row.count} +
  • + ))} +
+ )} + {summary.truncated && ( +

+ {t('console.assignedWork.truncated')} +

+ )} + {summary.unreadable.length > 0 && ( +

+ {t('console.assignedWork.unreadable', { objects: summary.unreadable.map(objectText).join(', ') })} +

+ )} +
+ ); +} + +function TransferReportView({ + report, + objectText, +}: { + report: AssignedWorkTransferReport; + objectText: (name: string) => string; +}) { + const { t } = useObjectTranslation(); + const failures = report.objects.flatMap((o) => o.failures.map((f) => ({ ...f, object: o.object }))); + return ( +
+

{t('console.assignedWork.report', { updated: report.updated, failed: report.failed, remaining: report.remaining })}

+ {failures.length > 0 && ( +
    + {failures.map((f) => ( +
  • + {t('console.assignedWork.failure', { object: objectText(f.object), id: f.id, message: f.message })} +
  • + ))} +
+ )} +
+ ); +} diff --git a/ui/packages/app-shell/src/views/actionDialogCompanion.test.ts b/ui/packages/app-shell/src/views/actionDialogCompanion.test.ts new file mode 100644 index 00000000000..39ac838728a --- /dev/null +++ b/ui/packages/app-shell/src/views/actionDialogCompanion.test.ts @@ -0,0 +1,38 @@ +/** + * Which param dialogs carry a host-composed companion (objectstack#30205). + * + * The platform's `ban_user` action on `sys_user` carries the user's + * assigned-work panel; every other action — the same object's other actions + * and another object's `ban_user` — carries none. A row that names no record + * carries none either, since there is no user to describe. + */ +import { describe, it, expect } from 'vitest'; +import { resolveParamDialogCompanion } from './actionDialogCompanion'; + +describe('resolveParamDialogCompanion (objectstack#30205)', () => { + it('gives the ban dialog the user-assigned-work panel for the row it runs on', () => { + expect(resolveParamDialogCompanion('sys_user', 'ban_user', { id: 'u_lee', name: 'Lee' })) + .toEqual({ kind: 'user-assigned-work', userId: 'u_lee' }); + }); + + it('reads a numeric id as its string', () => { + expect(resolveParamDialogCompanion('sys_user', 'ban_user', { id: 42 })) + .toEqual({ kind: 'user-assigned-work', userId: '42' }); + }); + + it('CONTROL: another action on sys_user carries none', () => { + expect(resolveParamDialogCompanion('sys_user', 'unban_user', { id: 'u_lee' })).toBeUndefined(); + expect(resolveParamDialogCompanion('sys_user', 'set_password', { id: 'u_lee' })).toBeUndefined(); + }); + + it('CONTROL: an action named ban_user on another object carries none', () => { + expect(resolveParamDialogCompanion('crm_contact', 'ban_user', { id: 'c1' })).toBeUndefined(); + expect(resolveParamDialogCompanion(undefined, 'ban_user', { id: 'u_lee' })).toBeUndefined(); + }); + + it('carries none when the row names no record', () => { + expect(resolveParamDialogCompanion('sys_user', 'ban_user', undefined)).toBeUndefined(); + expect(resolveParamDialogCompanion('sys_user', 'ban_user', { id: '' })).toBeUndefined(); + expect(resolveParamDialogCompanion('sys_user', 'ban_user', { name: 'Lee' })).toBeUndefined(); + }); +}); diff --git a/ui/packages/app-shell/src/views/actionDialogCompanion.ts b/ui/packages/app-shell/src/views/actionDialogCompanion.ts new file mode 100644 index 00000000000..39f0bf2eb2d --- /dev/null +++ b/ui/packages/app-shell/src/views/actionDialogCompanion.ts @@ -0,0 +1,46 @@ +/** + * The host-composed panel an action's param dialog shows beside its params. + * + * A companion is console chrome, not authored metadata: the dialog's host + * decides it from the platform action being run, the way it composes the + * admin-override notice (objectui#5178). Both param-collection handlers + * (`useConsoleActionRuntime` and `RecordDetailView`) ask + * {@link resolveParamDialogCompanion}, so the two runtimes cannot disagree on + * which dialog carries one. + * + * ## The one companion today: a banned user's open work (objectstack#30205) + * + * The platform's `ban_user` action on `sys_user` opens a dialog that shows how + * many records the person owns and is assigned, and offers one optional bulk + * hand-over (`UserAssignedWorkPanel`). The ban never waits for it: the + * dialog's Confirm stays the ban, and the panel neither gates nor runs it. + */ + +/** The platform object whose ban action carries the assigned-work companion. */ +export const USER_OBJECT = 'sys_user'; + +/** The platform action, on {@link USER_OBJECT}, that bans a user. */ +export const BAN_USER_ACTION = 'ban_user'; + +/** What a param dialog draws beside its params; absent = nothing. */ +export type ParamDialogCompanion = { + kind: 'user-assigned-work'; + /** The user the action is run on. */ + userId: string; +}; + +/** + * The companion for a param dialog, from the object the action runs on, the + * action's name and the row it runs on. `undefined` when the action carries + * none, or when the row names no record to describe. + */ +export function resolveParamDialogCompanion( + objectName: string | undefined, + actionName: string | undefined, + row: Record | undefined, +): ParamDialogCompanion | undefined { + if (objectName !== USER_OBJECT || actionName !== BAN_USER_ACTION) return undefined; + const id = row?.id; + if ((typeof id !== 'string' || id === '') && typeof id !== 'number') return undefined; + return { kind: 'user-assigned-work', userId: String(id) }; +} diff --git a/ui/packages/app-shell/src/views/userAssignedWork.ts b/ui/packages/app-shell/src/views/userAssignedWork.ts new file mode 100644 index 00000000000..13b440a43f4 --- /dev/null +++ b/ui/packages/app-shell/src/views/userAssignedWork.ts @@ -0,0 +1,27 @@ +/** + * The ban dialog's assigned-work vocabulary (objectstack#30205): the client's + * two answers, typed off the adapter's own client, and the owned/assigned + * split `UserAssignedWorkPanel` draws. + */ + +import type { ObjectStackAdapter } from '@object-ui/data-objectstack'; + +type AssignedWorkApi = ReturnType['security']['assignedWork']; +/** The count answer (`UserAssignedWorkSummary` of `@objectstack/client`). */ +export type AssignedWorkSummary = Awaited>; +/** The hand-over answer (`UserWorkTransferReport` of `@objectstack/client`). */ +export type AssignedWorkTransferReport = Awaited>; + +/** The platform owner field: a record whose owner is the user is "owned". */ +export const OWNER_FIELD = 'owner_id'; + +/** Owned and assigned totals of a count answer. */ +export function splitAssignedWork(summary: AssignedWorkSummary): { owned: number; assigned: number } { + let owned = 0; + let assigned = 0; + for (const row of summary.objects) { + if (row.field === OWNER_FIELD) owned += row.count; + else assigned += row.count; + } + return { owned, assigned }; +} diff --git a/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts b/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts index c3943ae00cd..5de203da8f8 100644 --- a/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts +++ b/ui/packages/i18n/src/__tests__/untranslated-identity-4376.test.ts @@ -149,6 +149,8 @@ const LEGITIMATE_IDENTITIES: Record = { 'appManagement.toast.bulkFailureEntry': '{{name}} ({{reason}}) — holes and parentheses only.', 'collaboration.userStatusTitle': '{{name}} ({{status}}) — holes and parentheses only.', 'chatbot.build.nextStepQuote': '“{{step}}” — a hole in quote marks; ko quotes with the same curly marks as en (objectui#12724).', + 'console.assignedWork.row': '{{object}} · {{field}} — two holes and a middle dot, no prose (objectstack#30205).', + 'console.assignedWork.failure': '{{object}} {{id}}: {{message}} — holes and a colon; zh sets a fullwidth colon, ja/ko/ru/ar keep `: ` (objectstack#30205).', }; /** Every string leaf of a pack, as `[dotted.path, value]`. */ diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index c2add4c92bc..440e0eb3c40 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2569,6 +2569,28 @@ const ar = { dark: "داكن", system: "النظام", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'السجلات التي يحتفظ بها هذا الشخص', + loading: 'جارٍ عدّ السجلات التي يحتفظ بها هذا الشخص…', + countFailed: 'تعذّر عدّ السجلات التي يحتفظ بها هذا الشخص: {{message}}', + noClient: 'لا يمكن عدّ السجلات التي يحتفظ بها هذا الشخص هنا.', + none: 'لا يحتفظ هذا الشخص بأي سجلات يمكنك قراءتها.', + summary: 'يحتفظ بـ {{total}} من السجلات التي يمكنك قراءتها: مالك لـ {{owned}}، ومُسند إليه {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'يضم هذا الحقل عدة أشخاص، لذا لا يمكن تسليمه هنا.', + truncated: 'لم تُعدّ كل حقول المستخدم؛ قد يحتفظ هذا الشخص بالمزيد.', + unreadable: 'لم تُعدّ لأنك لا تستطيع قراءتها: {{objects}}', + handoverTo: 'تسليم إلى', + handoverHint: 'اختياري. لا ينتظر الحظر اكتمال التسليم.', + handover: 'تسليم', + handingOver: 'جارٍ التسليم…', + handoverNext: 'تسليم الدفعة التالية', + targetBanned: 'هذا الشخص محظور. اختر مستخدمًا نشطًا.', + transferRefused: 'رُفض التسليم: {{message}}', + report: 'سُلّم {{updated}}، وتعذّر نقل {{failed}}، ولا يزال {{remaining}} لديه.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index b9c43ae38d6..dd476148486 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2330,6 +2330,28 @@ const de = { dark: "Dunkel", system: "System", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'Datensätze dieser Person', + loading: 'Datensätze dieser Person werden gezählt…', + countFailed: 'Die Datensätze dieser Person konnten nicht gezählt werden: {{message}}', + noClient: 'Die Datensätze dieser Person können hier nicht gezählt werden.', + none: 'Diese Person hat keine Datensätze, die Sie lesen können.', + summary: 'Datensätze, die Sie lesen können: {{total}}. Besitzer: {{owned}}, zugewiesen: {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'Enthält mehrere Personen und kann hier nicht übergeben werden.', + truncated: 'Nicht alle Benutzerfelder wurden gezählt; diese Person kann weitere halten.', + unreadable: 'Nicht gezählt, weil Sie sie nicht lesen können: {{objects}}', + handoverTo: 'Übergeben an', + handoverHint: 'Optional. Die Sperre wartet nicht auf die Übergabe.', + handover: 'Übergeben', + handingOver: 'Wird übergeben…', + handoverNext: 'Nächsten Stapel übergeben', + targetBanned: 'Diese Person ist gesperrt. Wählen Sie einen aktiven Benutzer.', + transferRefused: 'Die Übergabe wurde abgelehnt: {{message}}', + report: 'Übergeben: {{updated}}, nicht verschiebbar: {{failed}}, noch gehalten: {{remaining}}.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 2b432e11bff..91e83fc88fc 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -2882,6 +2882,28 @@ const en = { passwordsMore: 'More entries omitted — use the download.', passwordsDownload: 'Download CSV', }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'Records this person holds', + loading: 'Counting the records this person holds…', + countFailed: 'The records this person holds could not be counted: {{message}}', + noClient: 'The records this person holds cannot be counted here.', + none: 'This person holds no records you can read.', + summary: 'Records you can read: {{total}}, owns {{owned}}, assigned {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'Lists several people, so it cannot be handed over here.', + truncated: 'Not every user field was counted; this person may hold more.', + unreadable: 'Not counted, because you cannot read them: {{objects}}', + handoverTo: 'Hand over to', + handoverHint: 'Optional. The ban does not wait for the hand-over.', + handover: 'Hand over', + handingOver: 'Handing over…', + handoverNext: 'Hand over the next batch', + targetBanned: 'That person is banned. Pick an active user.', + transferRefused: 'The hand-over was refused: {{message}}', + report: 'Handed over {{updated}}, {{failed}} could not be moved, {{remaining}} still held.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 4e70bd38085..798fed59543 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2392,6 +2392,28 @@ const es = { dark: "Oscuro", system: "Sistema", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'Registros que tiene esta persona', + loading: 'Contando los registros que tiene esta persona…', + countFailed: 'No se pudieron contar los registros que tiene esta persona: {{message}}', + noClient: 'Aquí no se pueden contar los registros que tiene esta persona.', + none: 'Esta persona no tiene registros que usted pueda leer.', + summary: 'Registros que usted puede leer: {{total}}. En propiedad: {{owned}}, asignados: {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'Admite varias personas, así que no se puede traspasar aquí.', + truncated: 'No se contaron todos los campos de usuario; esta persona puede tener más.', + unreadable: 'No contados porque no puede leerlos: {{objects}}', + handoverTo: 'Traspasar a', + handoverHint: 'Opcional. El bloqueo no espera al traspaso.', + handover: 'Traspasar', + handingOver: 'Traspasando…', + handoverNext: 'Traspasar el siguiente lote', + targetBanned: 'Esa persona está bloqueada. Elija un usuario activo.', + transferRefused: 'El traspaso fue rechazado: {{message}}', + report: 'Traspasados: {{updated}}, sin mover: {{failed}}, todavía asignados: {{remaining}}.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 34e09486782..8443eb1c1e9 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2388,6 +2388,28 @@ const fr = { dark: "Sombre", system: "Système", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'Enregistrements détenus par cette personne', + loading: 'Décompte des enregistrements détenus par cette personne…', + countFailed: 'Impossible de compter les enregistrements détenus par cette personne : {{message}}', + noClient: 'Les enregistrements détenus par cette personne ne peuvent pas être comptés ici.', + none: 'Cette personne ne détient aucun enregistrement que vous pouvez lire.', + summary: 'Enregistrements que vous pouvez lire : {{total}}. En propriété : {{owned}}, assignés : {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'Ce champ contient plusieurs personnes et ne peut pas être transféré ici.', + truncated: 'Tous les champs utilisateur n’ont pas été comptés ; cette personne peut en détenir davantage.', + unreadable: 'Non comptés, car vous ne pouvez pas les lire : {{objects}}', + handoverTo: 'Transférer à', + handoverHint: 'Facultatif. Le bannissement n’attend pas le transfert.', + handover: 'Transférer', + handingOver: 'Transfert en cours…', + handoverNext: 'Transférer le lot suivant', + targetBanned: 'Cette personne est bannie. Choisissez un utilisateur actif.', + transferRefused: 'Le transfert a été refusé : {{message}}', + report: 'Transférés : {{updated}}, non déplacés : {{failed}}, encore détenus : {{remaining}}.', + failure: '{{object}} {{id}} : {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 24176ed28ad..9618a073519 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2332,6 +2332,28 @@ const ja = { dark: "ダーク", system: "システム", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'この人が担当しているレコード', + loading: 'この人が担当しているレコードを数えています…', + countFailed: 'この人が担当しているレコードを数えられませんでした: {{message}}', + noClient: 'ここではこの人が担当しているレコードを数えられません。', + none: 'この人が担当している、あなたが読み取れるレコードはありません。', + summary: 'あなたが読み取れるレコードのうち {{total}} 件を担当しています: 所有 {{owned}} 件、割り当て {{assigned}} 件。', + row: '{{object}} · {{field}}', + notTransferable: '複数の人を入れられる項目のため、ここでは引き継げません。', + truncated: 'すべてのユーザー項目は数えていません。担当レコードはさらにある可能性があります。', + unreadable: '読み取り権限がないため数えていません: {{objects}}', + handoverTo: '引き継ぎ先', + handoverHint: '任意です。利用停止は引き継ぎを待ちません。', + handover: '引き継ぐ', + handingOver: '引き継ぎ中…', + handoverNext: '次のまとまりを引き継ぐ', + targetBanned: 'その人は利用停止中です。有効なユーザーを選んでください。', + transferRefused: '引き継ぎは拒否されました: {{message}}', + report: '{{updated}} 件を引き継ぎました。{{failed}} 件は移せませんでした。まだ {{remaining}} 件を担当しています。', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 21267948d08..d0ea94b967d 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2330,6 +2330,28 @@ const ko = { dark: "다크", system: "시스템", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: '이 사람이 맡고 있는 레코드', + loading: '이 사람이 맡고 있는 레코드를 세는 중…', + countFailed: '이 사람이 맡고 있는 레코드를 셀 수 없습니다: {{message}}', + noClient: '여기서는 이 사람이 맡고 있는 레코드를 셀 수 없습니다.', + none: '이 사람이 맡고 있는, 읽을 수 있는 레코드가 없습니다.', + summary: '읽을 수 있는 레코드 중 {{total}}건을 맡고 있습니다: 소유 {{owned}}건, 할당 {{assigned}}건.', + row: '{{object}} · {{field}}', + notTransferable: '여러 사람을 넣는 필드라 여기서 인계할 수 없습니다.', + truncated: '모든 사용자 필드를 세지는 않았습니다. 더 맡고 있을 수 있습니다.', + unreadable: '읽기 권한이 없어 세지 않았습니다: {{objects}}', + handoverTo: '인계 대상', + handoverHint: '선택 사항입니다. 차단은 인계를 기다리지 않습니다.', + handover: '인계', + handingOver: '인계 중…', + handoverNext: '다음 묶음 인계', + targetBanned: '그 사람은 차단되었습니다. 활성 사용자를 선택하세요.', + transferRefused: '인계가 거부되었습니다: {{message}}', + report: '{{updated}}건 인계, {{failed}}건 이동 실패, 아직 {{remaining}}건을 맡고 있습니다.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index e403efd6df2..922f90f10e9 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2387,6 +2387,28 @@ const pt = { dark: "Escuro", system: "Sistema", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'Registros desta pessoa', + loading: 'Contando os registros desta pessoa…', + countFailed: 'Não foi possível contar os registros desta pessoa: {{message}}', + noClient: 'Os registros desta pessoa não podem ser contados aqui.', + none: 'Esta pessoa não tem registros que você possa ler.', + summary: 'Registros que você pode ler: {{total}}. Em propriedade: {{owned}}, atribuídos: {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'Aceita várias pessoas, por isso não pode ser transferido aqui.', + truncated: 'Nem todos os campos de usuário foram contados; esta pessoa pode ter mais.', + unreadable: 'Não contados porque você não pode lê-los: {{objects}}', + handoverTo: 'Transferir para', + handoverHint: 'Opcional. O banimento não espera pela transferência.', + handover: 'Transferir', + handingOver: 'Transferindo…', + handoverNext: 'Transferir o próximo lote', + targetBanned: 'Essa pessoa está banida. Escolha um usuário ativo.', + transferRefused: 'A transferência foi recusada: {{message}}', + report: 'Transferidos: {{updated}}, não movidos: {{failed}}, ainda com a pessoa: {{remaining}}.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 00e049aa3b3..a7b83bf9208 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2456,6 +2456,28 @@ const ru = { dark: "Тёмная", system: "Системная", }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: 'Записи этого человека', + loading: 'Подсчёт записей этого человека…', + countFailed: 'Не удалось подсчитать записи этого человека: {{message}}', + noClient: 'Здесь нельзя подсчитать записи этого человека.', + none: 'У этого человека нет записей, которые вы можете читать.', + summary: 'Записей, которые вы можете читать: {{total}}; владелец — {{owned}}, назначено — {{assigned}}.', + row: '{{object}} · {{field}}', + notTransferable: 'Поле содержит нескольких человек, поэтому его нельзя передать здесь.', + truncated: 'Подсчитаны не все пользовательские поля; записей может быть больше.', + unreadable: 'Не подсчитаны, так как у вас нет доступа на чтение: {{objects}}', + handoverTo: 'Передать', + handoverHint: 'Необязательно. Блокировка не ждёт передачи.', + handover: 'Передать', + handingOver: 'Передача…', + handoverNext: 'Передать следующую партию', + targetBanned: 'Этот человек заблокирован. Выберите активного пользователя.', + transferRefused: 'Передача отклонена: {{message}}', + report: 'Передано: {{updated}}, не удалось перенести: {{failed}}, осталось: {{remaining}}.', + failure: '{{object}} {{id}}: {{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: { diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index e9f150c4f97..34c64bad246 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2423,6 +2423,28 @@ const zh = { passwordsMore: '更多条目已省略——请使用下载。', passwordsDownload: '下载 CSV', }, + // objectstack#30205 — the ban dialog's account of a user's open work (app-shell UserAssignedWorkPanel). + assignedWork: { + title: '此人手上的记录', + loading: '正在统计此人手上的记录…', + countFailed: '无法统计此人手上的记录:{{message}}', + noClient: '此处无法统计此人手上的记录。', + none: '此人手上没有你可读取的记录。', + summary: '你可读取的记录中有 {{total}} 条在此人手上:拥有 {{owned}} 条,被指派 {{assigned}} 条。', + row: '{{object}} · {{field}}', + notTransferable: '该字段可填多人,无法在此交接。', + truncated: '并非所有用户字段都已统计,此人手上可能还有更多记录。', + unreadable: '以下对象你无权读取,未计入:{{objects}}', + handoverTo: '交接给', + handoverHint: '可选。封禁不必等交接完成。', + handover: '交接', + handingOver: '正在交接…', + handoverNext: '交接下一批', + targetBanned: '此人已被封禁,请选择一位在职用户。', + transferRefused: '交接被拒绝:{{message}}', + report: '已交接 {{updated}} 条,{{failed}} 条未能移交,仍有 {{remaining}} 条在此人手上。', + failure: '{{object}} {{id}}:{{message}}', + }, objectView: { // objectui#13330 — Setup → Users on a managed environment. managedUsers: {