Skip to content

Commit f84205b

Browse files
feat(app-shell): the ban dialog says how many records the user holds and offers an optional hand-over (#30855)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoRihuYgPAMj645pMLAKLevd)_ Fixes #30205 Clause-②: yes (widening) — 19 new `console.assignedWork.*` keys ship in every published `@object-ui/i18n` language pack, which publishes every key it carries. Metadata-protocol: none Supersedes #30652. Same change, carried onto a branch this seat can push: cut from `claude/project-thread-4vnhy6` at `6491bc3d` (the previous account is out of tokens), then `origin/main` merged in at `fd749a45e`, which includes PR #30640. The merge was clean; the diff against `main` is the same 24 files, +1342 / -0. ## 维护者速读 - 改了什么:控制台「封禁用户」对话框在封禁原因上方多了一块:显示此人手上你能读到的记录数(总数、拥有、被指派,按对象和字段逐行),读不到的对象和被截断的统计如实写出;可选地选一位在职用户点「交接」,一次最多移交 500 条,结果显示已移交、失败(附每条原因)和剩余,剩余时可继续下一批。封禁按钮照旧,任何时候都能直接确认。 - 为什么改:#30205 的界面半。员工离职被封禁后,他名下的工单无人知晓、无人接手;服务器半(计数与批量转交接口)已由 PR #30546 落地,这里把它接到封禁对话框上。本 PR 接替 #30652(原账号额度用尽),内容不变,只是合入了最新 main。 - 风险与回滚:只改前端;面板只对平台自己的 `sys_user` 的 `ban_user` 动作出现,由宿主判定,不读任何作者元数据,其他动作的对话框不变。首屏英文语言包增加约 370 B gz,PR #30640 落地后余量足够:现读数 57,396 / 60,000,上限未动。回滚即还原本 PR。 - 先看:`ui/packages/app-shell/src/views/UserAssignedWorkPanel.tsx`(面板与交接流程)、`ui/packages/app-shell/src/views/actionDialogCompanion.ts`(哪个对话框带面板)、`ui/packages/app-shell/src/views/ActionParamDialog.tsx` 的 companion 渲染处。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/ActionParamDialog.banAssignedWork-30205.test.tsx`,预期 12 个用例全绿(计数与拥有/指派拆分、读不到与截断、交接完成/部分失败/被拒、交接进行中仍可封禁)。 - 需要你做的:无,席位落地。 ## What changes - **A host-composed companion on the param dialog.** `ParamDialogState` gains an optional `companion`, drawn at the top of the dialog's scrolling region. The host decides it from the platform action being run: the pure `resolveParamDialogCompanion(objectName, actionName, row)` (`views/actionDialogCompanion.ts`) answers `{ kind: 'user-assigned-work', userId }` for `ban_user` on `sys_user`, with the user id read from the row the action runs on, and nothing for any other action. No authored key selects it, so `sys-user.object.ts` and the spec are untouched. Both param-collection handlers, `useConsoleActionRuntime`'s and `RecordDetailView`'s, call that one function, so the two runtimes cannot drift. - **`UserAssignedWorkPanel`** (app-shell internal, not exported). On open it calls `security.assignedWork.count(userId)` of `@objectstack/client` through the console adapter, as the signed-in administrator, and shows: - the total, **owned** (rows whose field is `owner_id`) and **assigned** (every other user field), then one row per object and field with its count, using the object's and field's translated labels (objects read by name through `readObjectsByName`, with its `openPathObjectReaders-12811` row); - `truncated` as a note that this person may hold more, `unreadable` as the named objects that were not counted, and `transferable: false` rows (multi-valued user fields) as counted but not handed over here; - a refused count inside the panel, without blocking anything. - **The optional hand-over.** When some row is transferable, a `user` picker (the platform's person picker, which already leaves out banned users; the panel also leaves out the user being banned) and a Hand over button. One click is one `security.assignedWork.transfer(userId, { toUserId })` call (the server moves at most 500 rows). The report shows moved / could not be moved / still held, and each failed record's object, id and reason as the server worded it. When rows remain, the button reads "Hand over the next batch". The counts are read again after each hand-over, and the previous counts stay drawn until the new ones arrive. A target the server refuses as banned (`reference_user_banned`) is said in the reader's language; any other refusal shows the server's message. - **The ban never waits.** The dialog's Confirm is still the ban. It stays enabled while a count or a hand-over runs, and after either fails. - **Strings.** 19 `console.assignedWork.*` keys in all ten packs, in the eager console namespace: none of them is a marketplace-page string, so none belongs in the lazy `marketplace` namespace PR #30640 created. `row` and `failure` are pure format strings and are added to `LEGITIMATE_IDENTITIES` in `untranslated-identity-4376.test.ts` with their reason. - **Changeset.** `.changeset/ban-dialog-assigned-work-handover.md`, `@object-ui/app-shell` and `@object-ui/i18n` patch. Branch: the harness-assigned `claude/project-thread-hackce`, not `claude/issue-30205-*`; the card is #30205. ## Tests - New `views/actionDialogCompanion.test.ts` (5): the ban dialog gets the companion for its row (string and numeric ids); CONTROL, another `sys_user` action (`unban_user`, `set_password`) gets none; CONTROL, `ban_user` on another object or with no object gets none; a row with no id gets none. - New `hooks/__tests__/useConsoleActionRuntime.banCompanion-30205.test.tsx` (2) and `views/RecordDetailView.banCompanion-30205.test.tsx` (2): each handler hands the dialog the companion for `ban_user` with `params._rowRecord`, and none for `unban_user`. - New `views/ActionParamDialog.banAssignedWork-30205.test.tsx` (12), with the client's `count` / `transfer` as recording doubles: one count for the dialog's user and the owned/assigned split; `splitAssignedWork` counts only `owner_id` as owned; truncated and unreadable notes; nothing readable means no hand-over offered; a refused count keeps Confirm enabled and Confirm still resolves; the hand-over button waits for a pick; a complete hand-over reports and recounts; a partial hand-over names each failure and offers the next batch, which calls transfer again; a banned target is the translated sentence; another refusal shows the server's reason; Confirm stays enabled and bans while a hand-over is in flight; no panel and no count without the companion. - `RecordDetailView.paramRuntimeParity-6431.test.tsx`: `companion` joins `DIALOG_READS`, since both runtimes now write it. - **Ablations** (from #30652, `scripts/ablation-replace.mjs` on committed `975b53ff`, each restored by the tool: blob equals HEAD, `git diff HEAD` empty; not re-run on this head. Of the three files, the merge brought `main` changes only into `RecordDetailView.tsx` (+40 / -10, outside the companion line); the handler and panel suites those ablations turned red are green on `58719174dd` in the run below): - `useConsoleActionRuntime.tsx`, `companion: resolveParamDialogCompanion(...)` replaced by `companion: undefined`: its handler test went red, 1 failed / 2, `expected undefined to deeply equal { kind: 'user-assigned-work', … }`. - The same replacement in `RecordDetailView.tsx`: its handler test red, 1 failed / 2, same message. - `ActionParamDialog.tsx`, the companion render guarded off: the panel suite red, 10 failed / 12 (the two that stay green are the pure split and the no-companion case). ## Acceptance notes - **Owned is 0 on most objects until #30646 lands.** The server's field list skips `system: true` fields, and the platform-injected `owner_id` is `system: true`, so only objects that declare `owner_id` themselves (in the showcase, `showcase_announcement`) count or move owned records. Measured on the live showcase in #30652: three `showcase_project` rows owned by the user were not counted. The panel shows the server's answer as it came and does not compute owned records itself; once the server counts the injected field, the owned figure appears with no change here. - **What "owned" means here.** Only rows whose field is `owner_id` are owned; every other user field (an app's `assignee`, `f_user` in the showcase) is assigned. - **Eager English pack, re-measured after the merge.** `CI=true vite build` of the console on `58719174dd`, `dist/eager-closure.json`: `i18n-locale-en` 57,396 gz with this branch's `en.ts` and 57,026 gz with `main`'s `en.ts` on the same tree, so +370 B. `pnpm --dir ui check:eager-closure` exits 0 (`i18n-locale-en 56.1 KB / 58.6 KB ceiling (headroom 2.5 KB)`). The ceiling is not raised. The eager chunk count is 103 on both builds. - **One hand-over per click.** The panel does not loop over batches by itself. A failed row still holds the user and is retried on the next call, so an automatic loop would count the same failures again; the administrator sees each batch's report and asks for the next. - **A language switch** relabels the panel and does not recount. - **Screenshots** (from #30652, live showcase, console via vite at 1440 wide, 2x): - before, the ban dialog on `main` `14c67d44`, with the description and the ban reason only: [en](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/before-ban-dialog-en-US-1440.png?raw=true), [zh-CN](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/before-ban-dialog-zh-CN-1440.png?raw=true); - after, the dialog with the counts: [en](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/after-1-ban-dialog-counts-en-US-1440.png?raw=true) (7 held: owns 3 on Announcement, assigned 2 on Field Zoo's single user field, plus 2 on its multi-valued field, marked not transferable), [zh-CN](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/after-1-ban-dialog-counts-zh-CN-1440.png?raw=true) (a second user who owns 2); - after one hand-over to another user: [en](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/after-2-handover-report-en-US-1440.png?raw=true) ("Handed over 5, 0 could not be moved, 0 still held", the recount leaving the 2 multi-valued rows), [zh-CN](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/after-2-handover-report-zh-CN-1440.png?raw=true); - after Confirm, the banned user's record page: [en](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/after-3-banned-record-en-US-1440.png?raw=true), [zh-CN](https://github.com/objectstack-ai/objectstack/blob/44ce98c65fa36cc386b4a1f2d4a14e3c0939f357/evidence/30205/after-3-banned-record-zh-CN-1440.png?raw=true). - Committed at `44ce98c6` (in this branch's history) and removed from the tree by the next commit. ## Verification All on head `58719174dd` (the merge of `origin/main` `fd749a45e` into `6491bc3d`) unless noted. Lock readings are the lock's own VERDICT lines. Head `5290d27016` changes only the changeset's `Clause-②:` line (to `yes (widening)`, matching this body); on it the changeset gates were re-run (below) and the derived list is the same 35. <details><summary>Affected tests</summary> - `pnpm --dir ui exec vitest run packages/i18n/ packages/app-shell/src/views/ActionParamDialog packages/app-shell/src/views/RecordDetailView packages/app-shell/src/hooks/__tests__/useConsoleActionRuntime packages/app-shell/src/views/actionDialogCompanion packages/app-shell/src/__tests__/ packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts packages/app-shell/src/console/marketplace/`: 322 files, 321 passed; 3450 tests passed, 13 skipped, 16 failed, all 16 in `spec-symbol-parity.test.ts`, whose probe reads `@objectstack/spec`'s `.d.ts` and found none (`expected 0 to be greater than 1000`) because the dependency build ran with `OS_SKIP_DTS=1`. After a declaration build of `@objectstack/spec`, that file: 41 / 41. The run covers every i18n suite walking the ten packs (key parity, untranslated identities), the product-neutral renderer ratchet and PR #30640's marketplace namespace suites. - `pnpm --filter @object-ui/app-shell run type-check` (source and tests, after a declaration build of `@object-ui/app-shell^...`): VERDICT command-exit 0. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 35 derived families, the same list as on #30652. All 35 exit 0 on `58719174dd`, including `pnpm --dir ui exec vitest run scripts/` (170 files passed, 2 skipped; 4979 tests passed). `--ran` reconciles 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN. - Outside the derived list: `pnpm --dir ui check:eager-closure` exit 0 (above); `pnpm --dir ui run check:i18n-keys`, `check:i18n-dead-keys`, `check:eager-locale-catalogues`, `check:new-line-citations` exit 0. - On `5290d27016`: `node scripts/check-adr-0087-registration.mjs --base origin/main`, `node scripts/check-changeset-no-major.mjs --base origin/main`, `node scripts/check-empty-changeset.mjs --base origin/main`, `node scripts/check-closing-keyword-parity.mjs`, `pnpm check:nul-bytes`, `pnpm check:changeset-gate-self-tests` exit 0. - Dogfood battery: not run. The title has no `!` and `Clause-②` has no `(narrowing)` arm. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibzvSNwtTjiRhhyeVt8)_ <!-- signed: domain:ui#3 · os-tesla · session_01L9qibzvSNwtTjiRhhyeVt8 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1da957 commit f84205b

24 files changed

Lines changed: 1342 additions & 0 deletions
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): the ban dialog says how many records the user holds, and offers one optional hand-over
7+
8+
Clause-②: yes (widening) — 19 new `console.assignedWork.*` keys ship in every published `@object-ui/i18n` language pack, which publishes every key it carries.
9+
Metadata-protocol: none
10+
11+
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.
12+
13+
- **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.
14+
- **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.
15+
- **The ban never waits.** Confirm is still the ban and stays enabled while a count or a hand-over runs, or after either fails.
16+
- **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.
17+
- **Strings.** New `console.assignedWork.*` keys in all ten language packs.

‎ui/packages/app-shell/src/console/__tests__/openPathObjectReaders-12811.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ const OBJECTS_READERS: Record<string, { kind: Kind; why: string }> = {
8383
'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)" },
8484
'packages/app-shell/src/hooks/useRecentItemLabel.ts': { kind: 'byName', why: "a recent object or view entry's object, by name (objectstack#29121)" },
8585
'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)" },
86+
'packages/app-shell/src/views/UserAssignedWorkPanel.tsx': { kind: 'byName', why: "the ban dialog's assigned-work panel, each counted object by name (objectstack#30205)" },
8687
'packages/app-shell/src/chrome/CommandPalette.tsx': { kind: 'off', why: "the Studio scope's object list, read while the palette lists Studio entries" },
8788
'packages/app-shell/src/console/home/HomeLayout.tsx': {
8889
kind: 'scope',
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* `useConsoleActionRuntime`'s param-collection handler hands the dialog the
11+
* host-composed companion `resolveParamDialogCompanion` picks
12+
* (objectstack#30205): the ban dialog carries the user's assigned-work panel
13+
* for the row it runs on, and any other action carries none.
14+
* `RecordDetailView`'s handler, the second reader of the same seam, has its
15+
* own copy of these pins.
16+
*/
17+
18+
import { describe, it, expect, beforeEach, vi } from 'vitest';
19+
import { render, act } from '@testing-library/react';
20+
import React from 'react';
21+
22+
vi.mock('react-router-dom', () => ({ useNavigate: () => vi.fn() }));
23+
vi.mock('@object-ui/auth', async (importOriginal) => ({
24+
...(await importOriginal<Record<string, unknown>>()),
25+
useAuth: () => ({ user: { id: 'u1', name: 'User', image: null }, activeOrganization: null }),
26+
createAuthenticatedFetch: () => vi.fn(),
27+
}));
28+
29+
/** The bundle, keyed `KEY_OBJECT|ACTION` (`global` for no key object). */
30+
let bundle: Record<string, string> = {};
31+
const asked: Array<{ objectName: string | undefined; actionName: string | undefined; fallback: unknown }> = [];
32+
const LANGUAGE = 'zh-CN';
33+
34+
// Partial mock: `@object-ui/components` reaches for `createSafeTranslation` at
35+
// import time, so a total double breaks the module graph.
36+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
37+
...(await importOriginal<typeof import('@object-ui/i18n')>()),
38+
useObjectLabel: () => ({
39+
fieldLabel: (_o: any, _n: any, l: any) => l,
40+
fieldOptionLabel: (_o: any, _f: any, _v: any, l: any) => l,
41+
actionParamText: (_o: any, _a: any, _p: any, _attr: any, fallback: any) => fallback,
42+
actionParamOptionLabel: (_o: any, _a: any, _p: any, _v: any, fallback: any) => fallback,
43+
actionDescription: (_o: any, _a: any, fallback: any) => fallback,
44+
actionSubmitText: (_o: any, _a: any, fallback: any) => fallback,
45+
actionCancelText: (objectName: string | undefined, actionName: string | undefined, fallback?: string) => {
46+
asked.push({ objectName, actionName, fallback });
47+
return bundle[`${objectName ?? 'global'}|${actionName}`] ?? (fallback || undefined);
48+
},
49+
}),
50+
useObjectTranslation: () => ({
51+
t: (key: string, options?: any) => String(options?.defaultValue ?? key),
52+
language: LANGUAGE,
53+
}),
54+
}));
55+
56+
vi.mock('../useActionModal', () => ({
57+
useActionModal: () => ({
58+
modalHandler: vi.fn(async () => ({ success: true })),
59+
modalElement: null,
60+
closeModal: () => {},
61+
resolveModalTarget: vi.fn(async () => null),
62+
}),
63+
}));
64+
65+
let paramDialogState: any = null;
66+
vi.mock('../../views/ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null }));
67+
vi.mock('../../views/ActionParamDialog', () => ({
68+
ActionParamDialog: ({ state }: any) => {
69+
if (state?.open) paramDialogState = state;
70+
return null;
71+
},
72+
}));
73+
vi.mock('../../views/ActionResultDialog', () => ({ ActionResultDialog: () => null }));
74+
vi.mock('../../views/FlowRunner', () => ({ FlowRunner: () => null }));
75+
vi.mock('sonner', () => {
76+
const fn: any = vi.fn();
77+
fn.error = vi.fn();
78+
fn.success = vi.fn();
79+
return { toast: fn };
80+
});
81+
82+
import { useConsoleActionRuntime } from '../useConsoleActionRuntime';
83+
84+
const PARAMS = [{ name: 'subject', label: 'Subject', type: 'text' }] as any[];
85+
86+
function Harness({ onReady }: { onReady: (fn: any) => void }) {
87+
const runtime = useConsoleActionRuntime({ dataSource: {}, objects: [] });
88+
const ready = React.useRef(false);
89+
if (!ready.current) {
90+
ready.current = true;
91+
onReady(runtime.actionProviderProps.onParamCollection);
92+
}
93+
return <>{runtime.dialogs}</>;
94+
}
95+
96+
async function collectParams(dispatch: Record<string, unknown>) {
97+
paramDialogState = null;
98+
let collect: any;
99+
render(<Harness onReady={(fn) => { collect = fn; }} />);
100+
await act(async () => {
101+
void collect(PARAMS, dispatch as any);
102+
await Promise.resolve();
103+
});
104+
expect(paramDialogState).toBeTruthy();
105+
}
106+
107+
beforeEach(() => {
108+
bundle = {};
109+
asked.length = 0;
110+
paramDialogState = null;
111+
});
112+
113+
describe('useConsoleActionRuntime — the ban dialog\'s companion (objectstack#30205)', () => {
114+
it('hands the ban dialog the assigned-work panel for the row it runs on', async () => {
115+
await collectParams({ name: 'ban_user', label: 'Ban User', objectName: 'sys_user', params: { _rowRecord: { id: 'u_lee', name: 'Lee' } } });
116+
expect(paramDialogState.companion).toEqual({ kind: 'user-assigned-work', userId: 'u_lee' });
117+
});
118+
119+
it('CONTROL: another sys_user action carries no companion', async () => {
120+
await collectParams({ name: 'unban_user', label: 'Unban User', objectName: 'sys_user', params: { _rowRecord: { id: 'u_lee' } } });
121+
expect(paramDialogState.companion).toBeUndefined();
122+
});
123+
});

‎ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { actionErrorDetail, captureUpdateUndoData, resolveRecordIdParamSeed, res
4444
import { useActionModal } from './useActionModal.js';
4545
import { ActionConfirmDialog, type ConfirmDialogState } from '../views/ActionConfirmDialog.js';
4646
import { ActionParamDialog, type ParamDialogState } from '../views/ActionParamDialog.js';
47+
import { resolveParamDialogCompanion } from '../views/actionDialogCompanion.js';
4748
import { ActionResultDialog, type ResultDialogState } from '../views/ActionResultDialog.js';
4849
import { FlowRunner, type ScreenFlowState, type ScreenSpec } from '../views/FlowRunner.js';
4950
import { FlowRefusalNotice, type FlowRefusalState } from '../views/FlowRefusalNotice.js';
@@ -370,6 +371,8 @@ export function useConsoleActionRuntime(opts: ConsoleActionRuntimeOptions): Cons
370371
// The runner's `run`, so Confirm runs the action from inside the
371372
// dialog and a refusal keeps what the user typed (objectui#13161).
372373
run,
374+
// The host-composed panel above the params (objectstack#30205).
375+
companion: resolveParamDialogCompanion(objForI18n, action?.name, row),
373376
});
374377
})(); });
375378
}, [objectName, objectDef, objects, metadata, fieldLabel, fieldOptionLabel, actionParamText, actionParamOptionLabel, actionSubmitText, actionCancelText, language]);

0 commit comments

Comments
 (0)