Skip to content

Commit c8e6d38

Browse files
feat(app-shell): Studio adds an action to an installed app's object, saved in its customization package (#30088) (#30211)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBAjDcpfVVoUY3KDhYsNEFWg)_ Fixes #30088 ## 维护者速读 - **改了什么**:已安装(只读)应用的对象,在 Studio「操作」页现在可以「新建」操作:同一套预设和编辑器,保存为定制包(`BASE.custom`)里的独立 `action` 条目(带 `objectName`),列在厂商操作之后、带「新增」标记,可编辑、可删除,「仅看定制」会计入它。 - **为什么改**:运营想给已安装 CRM 的线索加一个「标记合格」按钮;以前只有可写包能加操作,最常见的已安装场景没有入口。 - **风险与回滚**:仅 `@object-ui/app-shell` 前端(外加两条 i18n 文案);无 spec、服务端、元数据类型改动;可写包的操作仍写在对象草稿里,行为不变。回滚即 revert 本 PR。删除已发布的新增操作是立即生效的(与删除流程一致),不经过发布。 - **先看**:`pairObjectActions.ts`(读写定制包里的操作,首写走 ensure 门)和 `ObjectActionsPanel.tsx` 里 `pair` 分支(厂商行只读、新增行可编辑)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/DataPillar.pairActions-30088.test.tsx`(5 条全绿)。 - **需要你做的**:无,席位落地。 Clause-②: no Metadata-protocol: none ## What changes On a read-only base package (installed or code-shipped) that has, or can get, its paired customization package (ADR-0146 D3), the Data pillar's Actions view of a base object now offers New. Card Acceptance 1–4 all ship here (diff about 940 changed lines, under the 1,500 split line). 1. **New on a read-only base.** The Actions view gets an optional `pair` door from the Data pillar, passed only for a base object of a read-only base (`extendsBase`, the same condition the object-extension fields use). New offers the same presets (`actionPresets.ts`) and the same editor. A preset that needs no input is written at once; one that waits for a target is held in the panel as before and written by the first edit that gives it one. The write is a standalone `action` item into the pair, as a draft, `objectName` = the object, pinned `ifNoneMatch: '*'`. The first write asks the ensure door (`customization.ensure`) and every write names its answer, never a spelled `.custom` id. The object draft is not touched. 2. **Vendor rows read-only, added rows editable.** The vendor's actions are the served object's `actions` less any the pair holds (by name, and by `_packageId` = the pair, which the served object carries on a folded pair action: measured below). They open read-only with no Delete. The pair's actions on this object (its published list and its drafts, each draft read at the address its save writes so the version pins the save) are listed after them with the compact "added" `CustomizationMark`. An edit autosaves into the pair's draft through the shared `useDraftAutoSave` and `useDraftSaveGuard`. Delete removes the pair's draft and then its published row, as Delete flow does. An added action keeps the name it was created with: a rename is held with a line saying how to save (same rule and reason as the hooks panel). The read's top-level `_` keys are never written back. 3. **Pending changes and "only customizations".** The draft is in the pair, so it appears in the surface's pending changes and publishes with the pair's batch (no change needed there). The Actions list gets the "only customizations (n)" toggle (`RailCustomizationFilter`), counting the added actions that carry a mark and narrowing the list to them. 4. **Walled caller.** When the ensure door refuses this caller, New is not offered and the pair's actions open read-only. The object rail's own "only customizations" count is unchanged: it counts rows by the base's served customization read, which lists overrides and object-extension members and no standalone action. Counting a base object there for a pair action would need either a change to that read (server and spec) or a client-side computation the mark rules forbid, so the count lives on the Actions list instead. See Acceptance notes. New i18n key `engine.studio.actions.renameHeld` (en, zh). ## Tests `DataPillar.pairActions-30088.test.tsx`, real `DataPillar`, real Actions view and real action editor over a metadata client double, 5 tests: - New on a read-only base offers the four presets; `change_choice` writes one `action` save at the pair with `objectName`, `mode: 'draft'`, `ifNoneMatch: '*'`, asks the ensure door once, and writes no object; the vendor row has no Delete and a disabled editor; the added row carries the `added` mark. - A pair action (published and drafted) is listed once, as the pair's; a label edit autosaves into its draft at the pair with no `_packageId`; Delete resets its draft and then its row at the pair. - "only customizations" reads 1 and narrows the list to the pair's action. - Walled: no New, the pair's action read-only, no ensure call. - CONTROL: on a writable package the new action is written into the object draft's `actions`, with no `action` save and no ensure call. <details><summary>Ablations (each through scripts/ablation-replace.mjs, restored to the HEAD blob, git diff HEAD empty)</summary> - Drop the pair route (`extendsBase && customization` to `false && customization` in the Data pillar's Actions branch): 3 failed / 2 passed (walled and CONTROL stay green). - Drop the door gate (`canAdd = pair ? !pair.walled` to `pair ? true`): 1 failed (walled) / 4 passed. - Write the read's `_` keys back (`ownBody` keeps every key): 1 failed (the edit pin) / 4 passed. </details> ## Acceptance notes Browser check on a dev boot of `examples/app-crm` (`pnpm dev:crm -- --fresh`) with the console on Vite, on `f8bdead2` (before the merge of `main`). `com.example.crm` 4.0.0 is read-only and had no pair at boot. Screenshots are in the project's `ui-orders/30088-shots/` folder. - **Before** (main's two files served, confirmed by fetching the served module): the 操作 tab lists the vendor's Convert Lead with no 新建 (`01-before-no-new.png`). - **Add.** 新建 shows the presets (`02-after-new-menu-presets.png`). 修改下拉选择字段 sent `POST /packages/com.example.crm/ensure-customization` (201, pair created) and `PUT /meta/action/crm_lead_action_2?mode=draft&package=com.example.crm.custom` (200). The row shows the 新增 mark, 仅看定制 (1), 删除, and the top bar 变更 1 (`03-after-added-in-pair.png`). Label 标记合格 and placement 列表 · 行 autosaved into the pair's draft (`04-after-edited-saved-to-pair.png`). - **Publish.** 待发布的变更 lists 动作 标记合格 新增 under "保存到 CRM (minimal example) 的定制" (`05-after-pending-changes.png`); 全部发布 sent `POST /packages/com.example.crm.custom/publish-drafts`, `publishedCount: 1`. Back in Studio the action is listed once, marked (`06-after-published-listed-once-marked.png`). `GET /meta/object/crm_lead/layers?package=com.example.crm` served it in `effective.actions` with `_packageId: com.example.crm.custom`, beside the vendor's with none. - **Run.** The runtime row menu of a lead shows 标记合格 (`07-after-runtime-row-menu.png`); it asked for 状态 (`08-after-run-dialog.png`), and with 合格 sent `POST /actions/crm_lead/crm_lead_action_2` (200, `operation: update`); the row turned 合格 with "操作已成功完成" (`09-after-run-result.png`). - **Remove.** 删除 sent `DELETE /meta/action/crm_lead_action_2?package=com.example.crm.custom` (200); the list shows only Convert Lead (`10-after-removed-in-studio.png`) and the runtime row menu no longer has it (`11-after-removed-runtime-menu.png`). - A rename of an added action is held with the line, and no save is sent until the name is put back (`12-after-rename-held.png`); a second run confirmed the edit's PUT carries no `_packageId` and that 删除 of a draft-only action sends `DELETE ...?state=draft&package=com.example.crm.custom`. Observed, not changed: - The object rail's 仅看定制 does not count `crm_lead` for an added action (see What changes). If the maintainer wants the object row marked too, the base's customization read would have to list standalone pair actions on base objects: a spec and server change, out of this card (Acceptance 5). - Deleting a published added action takes effect at once, as Delete flow does; it does not wait for a publish. ## Verification All under `scripts/pm/os-verify-lock.sh` unless noted. Head `238e15e3` (main `474c030b` merged). - Build: full `pnpm build`, then `turbo run build --filter=@object-ui/app-shell...` after the merge: exit 0. - `@object-ui/app-shell` and `@object-ui/i18n` `type-check` (both tsconfigs each): exit 0. - Tests: every app-shell test file that imports `DataPillar`, `ObjectActionsPanel` or `pairObjectActions` (79 files) plus the whole `@object-ui/i18n` suite (95 files): 174 files, 1977 passed, 13 skipped. Narrowed from the 214 files importing `StudioDesignSurface`: this diff changes only the Data pillar's Actions branch there; CI's Test UI runs the rest. - `npx eslint` on the 4 touched source/test files (unlocked): 0 errors; the warnings are all pre-existing ones in `StudioDesignSurface.tsx`. - `node scripts/pm/dispatch-gates.mjs --commands` on head `238e15e3`: 35 commands, all recorded `:: exit 0` (including `check-sdui-manifest.mjs`, `vitest run scripts/` and `check:type-check-debt`); `--ran`: "35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN". - Changed lines against `main`: about 940 (+908 / -30, 7 files), under 3,000. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzemmgi8p8uY913FXHx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0c666c3 commit c8e6d38

7 files changed

Lines changed: 908 additions & 30 deletions

File tree

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): Studio adds an action to an installed app's object, saved in the base's customization package (#30088)
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
On a read-only (installed or code-shipped) package that has, or can get, its paired customization package (`<base>.custom`), the Data pillar's Actions view of the base's object now offers New. Before, it listed the vendor's actions with no New, so an operations person could not add a button such as "Mark qualified" to an installed app's leads, although the platform accepts the result.
12+
13+
- New offers the same presets and the same action editor as on a writable package. The action is saved as its own `action` item in the pair, bound to the object by `objectName`, as a draft (the first write asks the ensure door for the pair). The base's object is never written.
14+
- The vendor's actions stay read-only in the list. The added action is listed after them with the "added" customization mark, its edits autosave into its draft in the pair, and Delete removes its draft and its published item from the pair. An added action keeps the name it was created with: a rename is held with a line saying how to save.
15+
- The action publishes with the pair's other drafts from Pending changes, and the Actions list's "only customizations" counts it and narrows the list to the pair's actions.
16+
17+
A writable package's action editing is unchanged: its actions are still written into the object draft. No spec, server or metadata-type change.
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#30088 (ADR-0146 D3) — Studio adds an action to a read-only
5+
* base's object, saved as its own `action` item in the base's paired
6+
* `<base>.custom` package.
7+
*
8+
* The REAL `DataPillar`, the REAL Actions view and the REAL action editor,
9+
* over a metadata client double. The customization the surface hands the
10+
* pillar is built here: its `ensure` is the ensure door.
11+
*
12+
* Pinned:
13+
* - the Actions view of a read-only base's object offers New, with the same
14+
* presets, once the base has or can get its pair; a preset writes a new
15+
* `action` item into the pair as a draft (`objectName` = the object,
16+
* `ifNoneMatch: '*'`), asking the ensure door, and the object is not
17+
* written;
18+
* - the vendor's actions stay read-only (no Delete, the editor disabled);
19+
* the added one carries the "added" mark, edits autosave into the pair's
20+
* draft, and Delete removes its draft and its published row at the pair;
21+
* - "only customizations" counts the added action and narrows the list to it;
22+
* - walled: no New, and the pair's action opens read-only;
23+
* - CONTROL: on a writable package an action is still written into the
24+
* object draft (`actions`), and nothing is asked of the ensure door.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import * as React from 'react';
29+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
30+
// Module scope as well as in the `vi.mock` factory below: the cold transform is paid
31+
// during collection, which no test or hook timeout clocks (AGENTS.md, flaky-test discipline).
32+
import '@object-ui/components';
33+
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
34+
import { MemoryRouter } from 'react-router-dom';
35+
36+
const BASE = 'com.example.crm';
37+
const PAIR = 'com.example.crm.custom';
38+
const OBJECT = 'crm_lead';
39+
40+
/** The vendor's action, served on the object. */
41+
const VENDOR = {
42+
name: 'crm_lead_convert',
43+
label: 'Convert',
44+
objectName: OBJECT,
45+
locations: ['record_header'],
46+
type: 'script',
47+
body: { language: 'expression', source: 'true' },
48+
};
49+
50+
/** An action the pair already holds on the object: published, and with a pending draft. */
51+
const KEPT = {
52+
name: 'crm_lead_action_7',
53+
label: 'Qualify',
54+
objectName: OBJECT,
55+
locations: ['record_more'],
56+
operation: 'update',
57+
patch: { status: 'qualified' },
58+
};
59+
60+
const srv = vi.hoisted(() => ({
61+
/** The pair's published actions (every object's). */
62+
pairPublished: [] as Array<Record<string, unknown>>,
63+
/** The pair's pending action drafts, by name. */
64+
pairDrafts: {} as Record<string, Record<string, unknown>>,
65+
}));
66+
67+
const servedObject = () => ({
68+
name: OBJECT,
69+
label: 'Lead',
70+
fields: {
71+
name: { type: 'text', label: 'Name' },
72+
status: {
73+
type: 'select',
74+
label: 'Status',
75+
options: [
76+
{ label: 'New', value: 'new' },
77+
{ label: 'Qualified', value: 'qualified' },
78+
],
79+
},
80+
},
81+
// The published pair action is served folded into the object, under the pair.
82+
actions: [VENDOR, ...srv.pairPublished.filter((a) => a.objectName === OBJECT).map((a) => ({ ...a, _packageId: PAIR }))],
83+
});
84+
85+
const mockClient = {
86+
list: vi.fn(async (type: string, opts?: { packageId?: string }) => {
87+
if (type === 'action') return opts?.packageId === PAIR ? srv.pairPublished.map((a) => ({ ...a, _packageId: PAIR })) : [];
88+
if (type === 'object') return opts?.packageId === PAIR ? [] : [{ name: OBJECT, label: 'Lead' }];
89+
return [];
90+
}),
91+
listDrafts: vi.fn(async (opts?: { packageId?: string; type?: string }) =>
92+
opts?.type === 'action' && opts.packageId === PAIR
93+
? Object.keys(srv.pairDrafts).map((name) => ({ type: 'action', name }))
94+
: [],
95+
),
96+
layered: vi.fn(async () => ({ effective: servedObject(), code: servedObject() })),
97+
getDraft: vi.fn(async (type: string, name: string, opts?: { packageId?: string }) =>
98+
type === 'action' && opts?.packageId === PAIR && srv.pairDrafts[name]
99+
? // Served decorated, as the draft read serves it.
100+
{ type, name, item: { ...srv.pairDrafts[name], _packageId: PAIR }, version: 'v1' }
101+
: null,
102+
),
103+
get: vi.fn(async () => null),
104+
// The pair's action drafts are kept, so a read after a write serves them.
105+
save: vi.fn(async (type: string, name: string, body: unknown, opts?: { packageId?: string }) => {
106+
if (type === 'action' && opts?.packageId === PAIR) srv.pairDrafts[name] = body as Record<string, unknown>;
107+
return { ok: true, version: 'v2', state: 'draft' };
108+
}),
109+
reset: vi.fn(async (type: string, name: string, opts?: { packageId?: string; state?: string }) => {
110+
if (type === 'action' && opts?.packageId === PAIR) {
111+
if (opts.state === 'draft') delete srv.pairDrafts[name];
112+
else srv.pairPublished = srv.pairPublished.filter((a) => a.name !== name);
113+
}
114+
return { ok: true };
115+
}),
116+
withPreviewDrafts() {
117+
return this;
118+
},
119+
};
120+
121+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
122+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
123+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
124+
});
125+
126+
vi.mock('./packages-io', async (importOriginal) => {
127+
const mod = await importOriginal<typeof import('./packages-io')>();
128+
return { ...mod, fetchPackages: vi.fn(async () => []) };
129+
});
130+
131+
// The Advanced views live in a Radix DropdownMenu; rendered as passthroughs,
132+
// as in DataPillar.actionRefusal-26736.test.tsx.
133+
type PassthroughProps = { children?: React.ReactNode; onSelect?: () => void };
134+
vi.mock('@object-ui/components', async (importOriginal) => {
135+
const mod = await importOriginal<typeof import('@object-ui/components')>();
136+
return {
137+
...mod,
138+
DropdownMenu: (p: PassthroughProps) => <div>{p.children}</div>,
139+
DropdownMenuTrigger: (p: PassthroughProps) => <div>{p.children}</div>,
140+
DropdownMenuContent: (p: PassthroughProps) => <div>{p.children}</div>,
141+
DropdownMenuItem: (p: PassthroughProps) => (
142+
<button type="button" onClick={() => p.onSelect?.()}>{p.children}</button>
143+
),
144+
DropdownMenuRadioGroup: (p: PassthroughProps) => <div>{p.children}</div>,
145+
DropdownMenuRadioItem: (p: PassthroughProps) => (
146+
<button type="button" onClick={() => p.onSelect?.()}>{p.children}</button>
147+
),
148+
};
149+
});
150+
151+
vi.mock('@object-ui/react', async (importOriginal) => {
152+
const mod = await importOriginal<typeof import('@object-ui/react')>();
153+
return { ...mod, useAdapter: () => dataSource };
154+
});
155+
156+
import { DataPillar, type StudioBaseCustomization } from './StudioDesignSurface';
157+
import { createEmptyDataSource } from './__tests__/emptyDataSource';
158+
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
159+
import { t, tFormat } from '../metadata-admin/i18n';
160+
import { indexCustomizations } from './customizationMarks';
161+
import type { PackageCustomizationsState } from './CustomizationMark';
162+
163+
const dataSource = createEmptyDataSource();
164+
registerBuiltinInspectors();
165+
166+
const L = (key: string) => t(key, 'en');
167+
const ensure = vi.fn(async (): Promise<string> => PAIR);
168+
169+
/** The base's customization read: the pair overrides nothing. */
170+
function marksRead(): PackageCustomizationsState {
171+
const read = {
172+
packageId: BASE,
173+
overrides: [],
174+
extensions: [],
175+
switchedOff: [],
176+
contributions: [],
177+
counts: { overrides: 0, extensions: 0, switchedOff: 0 },
178+
} as never;
179+
return { status: 'read', read, index: indexCustomizations(BASE, read)!, baseName: 'CRM', contributorNames: new Map() };
180+
}
181+
182+
function customizationOf(over: Partial<StudioBaseCustomization> = {}): StudioBaseCustomization {
183+
return {
184+
base: BASE,
185+
baseLabel: 'CRM',
186+
basedOn: '4.0.0',
187+
walled: false,
188+
pairId: PAIR,
189+
ensure,
190+
ensurePackage: vi.fn() as unknown as StudioBaseCustomization['ensurePackage'],
191+
marks: marksRead(),
192+
...over,
193+
};
194+
}
195+
196+
beforeEach(() => {
197+
srv.pairPublished = [];
198+
srv.pairDrafts = {};
199+
ensure.mockImplementation(async () => PAIR);
200+
});
201+
202+
afterEach(() => {
203+
cleanup();
204+
vi.clearAllMocks();
205+
});
206+
207+
async function mountOnActions(props: { readOnly: boolean; customization?: StudioBaseCustomization | null }) {
208+
render(
209+
<MemoryRouter initialEntries={[`/studio/${BASE}/data`]}>
210+
<DataPillar packageId={BASE} readOnly={props.readOnly} customization={props.customization ?? null} />
211+
</MemoryRouter>,
212+
);
213+
// The first match is the flat tab (the passthrough menu draws a second).
214+
fireEvent.click((await screen.findAllByRole('button', { name: L('engine.studio.data.tab.actions') }, { timeout: 8000 }))[0]);
215+
await within(await screen.findByTestId('actions-list', undefined, { timeout: 8000 })).findByText('Convert');
216+
}
217+
218+
/** Past the autosave's 1.5 s debounce. */
219+
async function outlastDebounce(): Promise<void> {
220+
await act(async () => {
221+
await new Promise((r) => setTimeout(r, 2300));
222+
});
223+
}
224+
225+
type SaveCall = [string, string, Record<string, unknown>, Record<string, unknown>];
226+
const saves = () => mockClient.save.mock.calls as unknown as SaveCall[];
227+
const actionSaves = () => saves().filter(([type]) => type === 'action');
228+
const list = () => screen.getByTestId('actions-list');
229+
const listRow = (label: string) => within(list()).getByText(label).closest('button') as HTMLElement;
230+
231+
describe('Studio adds an action to an installed app’s object, in its customization package (objectstack#30088)', () => {
232+
it('New writes a preset into the pair as a standalone action draft; the object is not written', async () => {
233+
await mountOnActions({ readOnly: true, customization: customizationOf() });
234+
// The vendor's action: read-only, no Delete.
235+
expect(screen.queryByRole('button', { name: L('engine.studio.actions.delete') })).not.toBeInTheDocument();
236+
expect(screen.getByRole('textbox', { name: L('engine.inspector.action.label') })).toBeDisabled();
237+
238+
fireEvent.click(screen.getByRole('button', { name: L('engine.studio.new') }));
239+
// The same presets as a writable package's New.
240+
for (const id of ['change_choice', 'run_flow', 'open_url', 'open_page']) {
241+
expect(await screen.findByTestId(`action-preset-${id}`)).toBeInTheDocument();
242+
}
243+
fireEvent.click(screen.getByTestId('action-preset-change_choice'));
244+
245+
await waitFor(() => expect(actionSaves()).toHaveLength(1));
246+
const [, name, body, opts] = actionSaves()[0]!;
247+
expect(name).toMatch(/^crm_lead_action_\d+$/);
248+
expect(name).not.toBe(VENDOR.name);
249+
expect(body).toMatchObject({ name, objectName: OBJECT, operation: 'update', locations: ['record_header'] });
250+
expect(opts).toMatchObject({ mode: 'draft', packageId: PAIR, ifNoneMatch: '*' });
251+
expect(ensure).toHaveBeenCalledTimes(1);
252+
expect(saves().filter(([type]) => type === 'object')).toHaveLength(0);
253+
254+
// Listed after the vendor's, with the "added" mark, open and removable.
255+
const row = await waitFor(() => {
256+
const r = within(list()).getAllByRole('button').find((b) => b.querySelector('[data-testid="customization-mark"]'));
257+
expect(r).toBeTruthy();
258+
return r!;
259+
});
260+
expect(within(row).getByTestId('customization-mark')).toHaveAttribute('data-kind', 'added');
261+
expect(within(listRow('Convert')).queryByTestId('customization-mark')).toBeNull();
262+
expect(screen.getByRole('button', { name: L('engine.studio.actions.delete') })).toBeInTheDocument();
263+
}, 30_000);
264+
265+
it('the pair’s action edits into its draft at the pair, and Delete removes its draft and its row there', async () => {
266+
srv.pairPublished = [KEPT];
267+
srv.pairDrafts = { [KEPT.name]: KEPT };
268+
await mountOnActions({ readOnly: true, customization: customizationOf() });
269+
fireEvent.click(await within(list()).findByText('Qualify', undefined, { timeout: 8000 }));
270+
// Served on the object AND held by the pair: listed once, as the pair's.
271+
expect(within(list()).getAllByText('Qualify')).toHaveLength(1);
272+
expect(within(listRow('Qualify')).getByTestId('customization-mark')).toHaveAttribute('data-kind', 'added');
273+
274+
const label = screen.getByRole('textbox', { name: L('engine.inspector.action.label') });
275+
expect(label).toBeEnabled();
276+
fireEvent.change(label, { target: { value: 'Mark qualified' } });
277+
await outlastDebounce();
278+
await waitFor(() => expect(actionSaves()).toHaveLength(1));
279+
const [, name, body, opts] = actionSaves()[0]!;
280+
expect(name).toBe(KEPT.name);
281+
expect(body).toMatchObject({ name: KEPT.name, label: 'Mark qualified', objectName: OBJECT });
282+
// The read's decorations are not written back.
283+
expect(body).not.toHaveProperty('_packageId');
284+
expect(opts).toMatchObject({ mode: 'draft', packageId: PAIR });
285+
expect(saves().filter(([type]) => type === 'object')).toHaveLength(0);
286+
287+
fireEvent.click(screen.getByRole('button', { name: L('engine.studio.actions.delete') }));
288+
await waitFor(() => expect(mockClient.reset).toHaveBeenCalledTimes(2));
289+
expect(mockClient.reset.mock.calls).toEqual([
290+
['action', KEPT.name, { state: 'draft', packageId: PAIR }],
291+
['action', KEPT.name, { packageId: PAIR }],
292+
]);
293+
await waitFor(() => expect(within(list()).queryByText('Mark qualified')).not.toBeInTheDocument());
294+
expect(within(list()).getByText('Convert')).toBeInTheDocument();
295+
}, 30_000);
296+
297+
it('“only customizations” counts the added action and keeps it alone', async () => {
298+
srv.pairPublished = [KEPT];
299+
await mountOnActions({ readOnly: true, customization: customizationOf() });
300+
const only = await screen.findByTestId('actions-customizations-only', undefined, { timeout: 8000 });
301+
expect(only).toHaveTextContent(tFormat('engine.list.customizations.only', 'en', { marked: 1 }));
302+
fireEvent.click(only);
303+
await waitFor(() => expect(within(list()).queryByText('Convert')).not.toBeInTheDocument());
304+
expect(within(list()).getByText('Qualify')).toBeInTheDocument();
305+
}, 30_000);
306+
307+
it('walled: no New, and the pair’s action opens read-only', async () => {
308+
srv.pairPublished = [KEPT];
309+
await mountOnActions({ readOnly: true, customization: customizationOf({ walled: true }) });
310+
expect(screen.queryByRole('button', { name: L('engine.studio.new') })).not.toBeInTheDocument();
311+
fireEvent.click(await within(list()).findByText('Qualify', undefined, { timeout: 8000 }));
312+
expect(screen.getByRole('textbox', { name: L('engine.inspector.action.label') })).toBeDisabled();
313+
expect(screen.queryByRole('button', { name: L('engine.studio.actions.delete') })).not.toBeInTheDocument();
314+
expect(ensure).not.toHaveBeenCalled();
315+
}, 30_000);
316+
317+
it('CONTROL: on a writable package the action is written into the object draft, and no door is asked', async () => {
318+
await mountOnActions({ readOnly: false });
319+
fireEvent.click(screen.getByRole('button', { name: L('engine.studio.new') }));
320+
fireEvent.click(await screen.findByTestId('action-preset-change_choice'));
321+
await outlastDebounce();
322+
await waitFor(() => expect(saves().filter(([type]) => type === 'object').length).toBeGreaterThan(0));
323+
const [, , body] = saves().filter(([type]) => type === 'object').at(-1)!;
324+
const names = (body.actions as Array<Record<string, unknown>>).map((a) => a.name);
325+
expect(names).toContain(VENDOR.name);
326+
expect(names.some((n) => typeof n === 'string' && /^crm_lead_action_\d+$/.test(n))).toBe(true);
327+
expect(actionSaves()).toHaveLength(0);
328+
expect(ensure).not.toHaveBeenCalled();
329+
}, 30_000);
330+
});

0 commit comments

Comments
 (0)