Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/ban-dialog-assigned-work-handover.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ const OBJECTS_READERS: Record<string, { kind: Kind; why: string }> = {
'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',
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Record<string, unknown>>()),
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<string, string> = {};
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<typeof import('@object-ui/i18n')>()),
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<string, unknown>) {
paramDialogState = null;
let collect: any;
render(<Harness onReady={(fn) => { 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();
});
});
3 changes: 3 additions & 0 deletions ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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]);
Expand Down
Loading
Loading