Skip to content

Commit 584f7b8

Browse files
feat(app-shell): show a disable refused for a dependent validation rule whole on the Setup packages page (#31016)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6Jao8FStJxkcthsASPRhE7tkGn)_ Fixes #31003 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:Setup 软件包详情里,停用被服务端以 409 `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` 拒绝时,页面先用管理员的语言显示一句标题(为什么不能停用、先停用或修改规则),再完整显示服务端原话(包 · 对象 · 规则,以及补救办法),不再只露第一句、把补救藏在「更多」后面。停用确认框里多一行,说明有规则依赖这个包时停用可能会被拒绝。 - 为什么改:#30825 之后停用可能被拒,但页面把「停用」画成随时可用,被拒时补救办法又被折叠,管理员看不出该先去关哪条规则。 - 风险与回滚:只改前端显示和两条新文案(en/zh),其它拒绝码的显示方式不变,服务端零改动。回滚即 revert 本 PR。 - 先看:`PackagesPage.tsx` 里 `run()` 的 catch(按错误码加 heading)和消息框的渲染分支;确认框里新增的 `pkg-detail-disable-may-refuse` 一行;测试文件末尾新的 describe。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx`,预期 15 passed,其中 en、zh 两条 409 用例都看到标题和完整原话。 - 需要你做的:无,席位落地。 ## What changes `ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx` (package detail sheet): - **The refusal, drawn whole.** When an action on the sheet fails with error code `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (read off the thrown error with `errorCodeIs`, which `apiJson` already fills from the ADR-0112 envelope), the message box draws a heading in the reader's language (`engine.packages.detail.disableRestricted`), and under it the server's whole message as sent. The server's message names every blocking rule as package · object · rule and then the remedy. Before, `RefusalMessage` cut it to its first sentence, so the remedy was behind "More". Every other refusal is drawn as before (`RefusalMessage`). - **Disable no longer reads as unconditional.** The confirm dialog gains one line (`engine.packages.detail.disableMayBeRefused`) saying the disable is refused while an active validation rule reads a field this package adds, and the refusal names each rule. Offering Disable still follows the served `disableable` verdict alone. Nothing is computed in the client. `ui/packages/i18n/src/namespaces/studio/{en,zh}.ts`: the two new keys. They are in the lazy `studio` namespace, not the eager `i18n-locale-en` pack, so no ceiling moves. ### What the server serves (measured on `origin/main` `8ae0ab9b`, `packages/runtime/src/domains/packages.ts`) - `PATCH /packages/:id/disable` answers `deps.error(disableDependentRulesMessage(...), 409, { code })`. On the wire that is `{ success: false, error: { code, message } }`, with no `error.details`. The JSDoc on the constant `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` says a machine-readable shape for the triples would be a spec contract first. The page therefore shows the message and does not parse it. - The served `disableable` is `isDisableablePackage` (a writable base, or a module of an installed app). It does not read `readDisableDependentRules`. Pre-computing it per row would need that server read on every `GET /packages` row (a fold of every object per package) and a change in what the field means. That is outside this card's fence (`packages/**`), so the page shows the refusal when the disable is attempted. ## Tests `PackagesPage.moduleDisable-28118.test.tsx` gets a new top-level describe, `a disable the server refuses for a dependent validation rule (objectstack#31003)`, in the existing module-disable harness. Its wire stub gains an optional per-door answer. The describe has 5 cases: - the confirm dialog carries the may-be-refused line beside the existing consequence copy; - **en** and **zh**: a 409 with the code draws the heading in that language and the server's whole sentence (rule listing and remedy both on screen, no "More"), and Disable stays on offer; - CONTROL: another refusal code (403 `WRITABLE_PACKAGE_REQUIRED`, two sentences) keeps the first-sentence drawing with "More" and no heading; - CONTROL: a disable with no dependent rule lands in one confirm (one `PATCH …/disable`, success line, no refusal drawn). The suite also gains module-top warm imports of `@objectstack/formula` and `@objectstack/lint`, which `check:test-source-alias` asks of an edited test whose reach loads them lazily. ## Acceptance notes - The heading is console copy and the listing stays the server's English sentence. Translating the listing would need the package · object · rule triples as data (`error.details`), which is a `packages/spec` contract and not opened here. - The heading is keyed on the error code, not on the door, so the same drawing would apply to that code wherever the sheet's `run()` meets it. Today only the disable door answers it on this sheet. The install door also answers it for `enableOnInstall: false`, but that door belongs to the import flow and is not touched here. ## Verification Head `0b6d41bd`, tree `objectstack-ai/objectstack`. <details><summary>Tests</summary> - `pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx`: Tests 15 passed (15), 10 existing and 5 new. - Every `PackagesPage.*.test.tsx` (18 files) on the first head: 17 passed, plus 1 failure in the new zh case (the test clicked the English label), fixed in the next commit. The file is green since then. - `pnpm --dir ui exec vitest run packages/i18n/src/__tests__/studio-namespace.test.ts`: 18 passed (en ⊆ zh, placeholder parity). - Ablation (`node scripts/ablation-replace.mjs`, anchor `{msg.kind === 'err' && msg.heading ? (` → `… && false ? (`): the en and zh 409 cases fail (2 failed, 13 passed). Restored to a blob equal to HEAD, and `git diff HEAD` is empty. </details> <details><summary>Derived gates: 36 of 36 run</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 36 lines. All 36 were run with stdin redirected from /dev/null, and `--ran` reports `36 derived famil(ies) accounted for — 36 run, 0 NOT-MEASURED`. On the first pass, 34 exited 0 and 2 exited 1: - `pnpm check:test-source-alias`: the edited suite reaches lazy `@objectstack/formula` and `@objectstack/lint` imports. Fixed with the warm imports. Exit 0 on `0b6d41bd`. - `pnpm --dir ui exec vitest run scripts/`: 7 failures in `check-doc-snippet-types.test.ts` and `spec-symbol-ledger-plugin-detail-7265.test.ts`. Both read `@objectstack/spec` declarations, and this worktree's spec was built with `OS_SKIP_DTS=1`. After `env -u OS_SKIP_DTS pnpm --filter @objectstack/spec build`, the full run on `0b6d41bd` gives Test Files 170 passed and 2 skipped; Tests 4991 passed and 2 skipped; `VERDICT command-exit 0`. Also run beyond the derivation, all exit 0: `pnpm --dir ui check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys` (neither new key is listed), `check:new-line-citations` (0 new), `pnpm check:undeclared-dep-imports`. `node scripts/check-sdui-manifest.mjs` is among the derived 36. </details> <details><summary>Not run locally</summary> `pnpm --filter @object-ui/app-shell run type-check` is NOT MEASURED. This worktree's `@object-ui/*` packages have no `.d.ts`, so the run reports about 1,700 missing-module errors (TS2307 and TS7006), and none of them is on a line this diff adds. Type Check UI runs it. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01L9qibwxPsNfCDtuhas4VDr)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 33bc8ae commit 584f7b8

5 files changed

Lines changed: 158 additions & 8 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(app-shell): the Setup packages page shows a disable refused for a dependent validation rule whole, and its confirm dialog says such a refusal can happen
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
When `PATCH /api/v1/packages/:id/disable` answers `409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE` (an active validation rule of an enabled package reads a field the package adds through `objectExtensions`), the package sheet now draws a heading in the reader's language over the server's whole sentence: every blocking rule as package · object · rule, then the remedy. Before, the message was cut to its first sentence and the remedy sat behind "More". Every other refusal is drawn as before. The Disable confirm dialog gains one line saying that such a rule can still refuse the disable, because the served `disableable` verdict says what the package allows and does not read rules elsewhere. Two new keys in the `@object-ui/i18n` `studio` namespace (`en` and `zh`): `engine.packages.detail.disableRestricted` and `engine.packages.detail.disableMayBeRefused`.

‎ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx‎

Lines changed: 100 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({
3636
import { PackageDetailSheet, PackagesPage, type InstalledPackageRow } from './PackagesPage';
3737
import { t } from './i18n';
3838
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config';
39+
import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio';
40+
// The sheet's package form reaches the CEL authoring helpers, which load these
41+
// two lazily; warmed here so their first load is paid at import, not inside a
42+
// test's bounded wait.
43+
import '@objectstack/formula';
44+
import '@objectstack/lint';
45+
46+
// The zh catalogue of the designer strings is fetched on first use; loaded here
47+
// so a zh render answers in zh from its first paint.
48+
const ZH = await loadStudioCatalogue('zh');
3949

4050
const label = (key: string) => t(`engine.packages.detail.${key}`, 'en');
4151

@@ -72,15 +82,20 @@ const reply = (status: number, body: unknown) =>
7282

7383
let calls: { path: string; method: string }[];
7484

75-
/** Serve the drafts read, the lifecycle doors and (for the page) the list door. */
76-
function stubWire(packages: unknown[] = []) {
85+
/**
86+
* Serve the drafts read, the lifecycle doors and (for the page) the list door.
87+
* `answer` serves a door's own reply first (a refusal), when it returns one.
88+
*/
89+
function stubWire(packages: unknown[] = [], answer?: (path: string, method: string) => Response | undefined) {
7790
calls = [];
7891
vi.stubGlobal(
7992
'fetch',
8093
vi.fn(async (input: unknown, init?: { method?: string }) => {
8194
const path = new URL(String(input), 'http://console.test').pathname;
8295
const method = (init?.method ?? 'GET').toUpperCase();
8396
calls.push({ path, method });
97+
const own = answer?.(path, method);
98+
if (own) return own;
8499
if (path.startsWith('/api/v1/meta/_drafts')) return reply(200, { success: true, data: { drafts: [] } });
85100
if (path === '/api/v1/packages' && method === 'GET') return reply(200, { success: true, data: { packages } });
86101
if (path === '/api/v1/cloud-connection/installed') return reply(404, {});
@@ -103,11 +118,16 @@ const settle = () =>
103118
await new Promise((r) => setTimeout(r, 0));
104119
});
105120

106-
async function renderSheet(pkg: InstalledPackageRow) {
107-
render(
121+
async function renderSheet(pkg: InstalledPackageRow, language?: 'zh') {
122+
const sheet = (
108123
<MemoryRouter>
109124
<PackageDetailSheet pkg={pkg} open onOpenChange={() => {}} onChanged={() => {}} />
110-
</MemoryRouter>,
125+
</MemoryRouter>
126+
);
127+
render(
128+
language
129+
? <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>{sheet}</I18nProvider>
130+
: sheet,
111131
);
112132
await screen.findByTestId('pkg-scope');
113133
await settle();
@@ -220,3 +240,78 @@ describe('Installed Apps view — a marketplace suite\'s module row (objectstack
220240
expect(screen.queryByRole('button', { name: label('disable') })).toBeNull();
221241
});
222242
});
243+
244+
describe('PackageDetailSheet — a disable the server refuses for a dependent validation rule (objectstack#31003)', () => {
245+
const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE';
246+
// The disable door's own sentence, as it spells it: the rule as
247+
// package · object · rule, then the remedy.
248+
const LISTING = 'com.acme.suite.custom · account · credit_cap (reads credit_limit)';
249+
const REMEDY = 'Switch off or change that rule first, or keep the package enabled.';
250+
const SENTENCE =
251+
`Package '${MODULE_ID}' cannot be disabled: an active validation rule reads a field it adds through ` +
252+
`objectExtensions, which the disable would withhold — ${LISTING}. ${REMEDY}`;
253+
const refuseDisable = (status: number, code: string, message: string) => (path: string, method: string) =>
254+
method === 'PATCH' && path.endsWith('/disable')
255+
? reply(status, { success: false, error: { code, message } })
256+
: undefined;
257+
258+
/** The sheet's Disable, in the language it renders. */
259+
const disableLabel = (language?: 'zh') => (language ? ZH['engine.packages.detail.disable'] : label('disable'));
260+
261+
async function confirmDisable(language?: 'zh') {
262+
fireEvent.click(screen.getByRole('button', { name: disableLabel(language) }));
263+
const dialog = await screen.findByTestId('pkg-detail-disable-dialog');
264+
fireEvent.click(within(dialog).getByTestId('pkg-detail-disable-confirm'));
265+
await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['disable']));
266+
await settle();
267+
}
268+
269+
it('the confirm dialog says a dependent validation rule can still refuse the disable', async () => {
270+
stubWire();
271+
await renderSheet(moduleRow());
272+
fireEvent.click(screen.getByRole('button', { name: label('disable') }));
273+
const dialog = await screen.findByTestId('pkg-detail-disable-dialog');
274+
expect(within(dialog).getByTestId('pkg-detail-disable-may-refuse')).toHaveTextContent(label('disableMayBeRefused'));
275+
expect(within(dialog).getByText(label('disableDescription'))).toBeInTheDocument();
276+
});
277+
278+
it.each([
279+
['en', undefined, label('disableRestricted')],
280+
['zh', 'zh' as const, ZH['engine.packages.detail.disableRestricted']],
281+
])('(%s) a 409 draws the heading in the reader\'s language and the server\'s whole sentence', async (_name, language, heading) => {
282+
expect(heading, 'the catalogue carries the heading').toBeTruthy();
283+
stubWire([], refuseDisable(409, CODE, SENTENCE));
284+
await renderSheet(moduleRow(), language);
285+
if (language) expect(heading).not.toBe(label('disableRestricted'));
286+
await confirmDisable(language);
287+
288+
expect(await screen.findByTestId('pkg-detail-refusal-heading')).toHaveTextContent(heading!);
289+
const text = screen.getByTestId('pkg-detail-refusal-text');
290+
// Whole, never cut to its first sentence: the rule listing and the remedy
291+
// are both on screen, with no "More" in front of either.
292+
expect(text).toHaveTextContent(LISTING);
293+
expect(text).toHaveTextContent(REMEDY);
294+
expect(text.textContent).toContain(SENTENCE);
295+
expect(screen.queryByTestId('refusal-more')).toBeNull();
296+
// Still on offer: the package was not switched off.
297+
expect(screen.getByRole('button', { name: disableLabel(language) })).toBeInTheDocument();
298+
});
299+
300+
it('CONTROL: another refusal code keeps the first-sentence drawing, with no heading', async () => {
301+
stubWire([], refuseDisable(403, 'WRITABLE_PACKAGE_REQUIRED', 'Package is read-only. Disable it from its owner.'));
302+
await renderSheet(moduleRow());
303+
await confirmDisable();
304+
expect(await screen.findByTestId('refusal-lead')).toHaveTextContent('Package is read-only.');
305+
expect(screen.getByTestId('refusal-more')).toBeInTheDocument();
306+
expect(screen.queryByTestId('pkg-detail-refusal-heading')).toBeNull();
307+
});
308+
309+
it('CONTROL: a disable with no dependent rule lands in one confirm, with no refusal drawn', async () => {
310+
stubWire();
311+
await renderSheet(moduleRow());
312+
await confirmDisable();
313+
expect(await screen.findByText(label('disabled'))).toBeInTheDocument();
314+
expect(screen.queryByTestId('pkg-detail-refusal-heading')).toBeNull();
315+
expect(lifecycleCalls()).toEqual(['disable']);
316+
});
317+
});

‎ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,14 @@ type UncheckedPackageRow = Omit<InstalledPackageRow, 'source' | 'writable'> & {
188188

189189
const API = '/api/v1/packages';
190190

191+
/**
192+
* The code `PATCH /packages/:id/disable` answers, with `409`, while a
193+
* validation rule of an enabled package reads a field the package adds through
194+
* `objectExtensions` (registered in the spec's error-code ledger). Its message
195+
* names every such rule and the remedy; the refusal carries no `details`.
196+
*/
197+
const DISABLE_RESTRICTED_BY_VALIDATION_RULE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE';
198+
191199
async function apiJson<T>(path: string, init?: RequestInit): Promise<T> {
192200
// objectstack#29119 — every write this page sends (publish, revert, discard,
193201
// delete, adopt, enable/disable, import) may change the package list, so the
@@ -518,11 +526,14 @@ export function PackageDetailSheet({
518526
const client = useMetadataClient();
519527
const [busy, setBusy] = React.useState<string | null>(null);
520528
// `details`: the lines a refusal carries under its sentence, in the server's
521-
// own words (objectui#13160).
529+
// own words (objectui#13160). `heading`: a console sentence, in the reader's
530+
// language, drawn above a refusal that is then shown whole rather than cut
531+
// to its first sentence (objectstack#31003).
522532
const [msg, setMsg] = React.useState<{
523533
kind: 'ok' | 'err';
524534
text: string;
525535
details?: readonly string[];
536+
heading?: string;
526537
} | null>(null);
527538
// ADR-0033 — pending DRAFT items bound to this package. AI-authored metadata
528539
// lands as drafts that the active-only browsers hide, so without this the
@@ -629,6 +640,14 @@ export function PackageDetailSheet({
629640
kind: 'err',
630641
text: e?.message ?? t('engine.packages.detail.actionFailed', locale),
631642
...(e instanceof PackagePublishRefusal ? { details: e.reasons } : {}),
643+
// objectstack#31003 — the disable door refuses while an enabled
644+
// validation rule reads a field the package adds. The server's sentence
645+
// names every such rule (package · object · rule) and then the remedy,
646+
// so it is drawn whole under a heading in the reader's language: cut to
647+
// its first sentence, the remedy sat behind "More".
648+
...(errorCodeIs(e, DISABLE_RESTRICTED_BY_VALIDATION_RULE)
649+
? { heading: t('engine.packages.detail.disableRestricted', locale) }
650+
: {}),
632651
});
633652
} finally {
634653
setBusy(null);
@@ -1174,8 +1193,22 @@ export function PackageDetailSheet({
11741193
}`}
11751194
>
11761195
{/* A refusal leads with its first sentence; the rest of the
1177-
server's text is behind "More" (objectui#13332). */}
1178-
{msg.kind === 'err' ? <RefusalMessage text={msg.text} /> : msg.text}
1196+
server's text is behind "More" (objectui#13332). One with a
1197+
heading is drawn whole under it (objectstack#31003). */}
1198+
{msg.kind === 'err' && msg.heading ? (
1199+
<>
1200+
<p className="font-medium" data-testid="pkg-detail-refusal-heading">
1201+
{msg.heading}
1202+
</p>
1203+
<p className="mt-1 whitespace-pre-wrap break-words select-text" data-testid="pkg-detail-refusal-text">
1204+
{msg.text}
1205+
</p>
1206+
</>
1207+
) : msg.kind === 'err' ? (
1208+
<RefusalMessage text={msg.text} />
1209+
) : (
1210+
msg.text
1211+
)}
11791212
{msg.details && msg.details.length > 0 && (
11801213
// Not capped: the sheet scrolls (`overflow-y-auto`), as it does
11811214
// for the pending-drafts list above. `break-words` keeps a long
@@ -1301,6 +1334,13 @@ export function PackageDetailSheet({
13011334
<AlertDialogDescription>
13021335
{t('engine.packages.detail.disableDescription', locale)}
13031336
</AlertDialogDescription>
1337+
{/* objectstack#31003 — the served `disableable` says what the
1338+
package allows, not whether a rule elsewhere still reads its
1339+
fields; the door answers that only when asked, so the dialog
1340+
says the disable can still be refused. */}
1341+
<p className="text-sm text-muted-foreground" data-testid="pkg-detail-disable-may-refuse">
1342+
{t('engine.packages.detail.disableMayBeRefused', locale)}
1343+
</p>
13041344
</AlertDialogHeader>
13051345
<AlertDialogFooter>
13061346
<AlertDialogCancel data-testid="pkg-detail-disable-cancel">

‎ui/packages/i18n/src/namespaces/studio/en.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1933,6 +1933,8 @@ const STUDIO_EN: Record<string, string> = {
19331933
'engine.packages.detail.disable': 'Disable',
19341934
'engine.packages.detail.disableTitle': 'Disable "{name}"?',
19351935
'engine.packages.detail.disableDescription': 'The package is taken out of service for every user until it is enabled again. No records are deleted, and you can enable it again at any time.',
1936+
'engine.packages.detail.disableMayBeRefused': 'The disable is refused while an active validation rule reads a field this package adds; the refusal names each such rule.',
1937+
'engine.packages.detail.disableRestricted': 'This package cannot be disabled while an active validation rule reads a field it adds. Switch off or change each rule named below first, or keep the package enabled.',
19361938
'engine.packages.detail.export': 'Export',
19371939
'engine.packages.detail.exporting': 'Exporting…',
19381940
'engine.packages.detail.deletePackage': 'Delete package',

‎ui/packages/i18n/src/namespaces/studio/zh.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1745,6 +1745,8 @@ const STUDIO_ZH: Record<string, string> = {
17451745
'engine.packages.detail.disable': '禁用',
17461746
'engine.packages.detail.disableTitle': '禁用「{name}」?',
17471747
'engine.packages.detail.disableDescription': '在重新启用之前,此软件包将对所有用户停止服务。不会删除任何记录,您可以随时重新启用。',
1748+
'engine.packages.detail.disableMayBeRefused': '如果有生效中的验证规则读取此软件包添加的字段,禁用会被拒绝,拒绝信息会逐条列出这些规则。',
1749+
'engine.packages.detail.disableRestricted': '有生效中的验证规则读取此软件包添加的字段,因此暂时无法禁用。请先停用或修改下方列出的每条规则,或保持此软件包启用。',
17481750
'engine.packages.detail.export': '导出',
17491751
'engine.packages.detail.exporting': '导出中…',
17501752
'engine.packages.detail.deletePackage': '删除软件包',

0 commit comments

Comments
 (0)