From a8bb5ba34dbc7deebef01f71aacb79b4933fc58e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:07:16 +0000 Subject: [PATCH 1/4] feat(app-shell): show the disable refusal for a dependent validation rule whole on the Setup packages page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A 409 PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE from the disable door is drawn under a heading in the reader's language with the server's whole sentence (package · object · rule and the remedy), no longer cut to its first sentence. The disable confirm dialog says such a rule can still refuse the disable, since the served disableable verdict does not read dependent rules. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwxPsNfCDtuhas4VDr --- .../PackagesPage.moduleDisable-28118.test.tsx | 97 ++++++++++++++++++- .../src/views/metadata-admin/PackagesPage.tsx | 46 ++++++++- ui/packages/i18n/src/namespaces/studio/en.ts | 2 + ui/packages/i18n/src/namespaces/studio/zh.ts | 2 + 4 files changed, 139 insertions(+), 8 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx index f9c0d3dd04b..0609448f984 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx @@ -36,6 +36,11 @@ vi.mock('@object-ui/auth', async (importOriginal) => ({ import { PackageDetailSheet, PackagesPage, type InstalledPackageRow } from './PackagesPage'; import { t } from './i18n'; import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config'; +import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; + +// The zh catalogue of the designer strings is fetched on first use; loaded here +// so a zh render answers in zh from its first paint. +const ZH = await loadStudioCatalogue('zh'); const label = (key: string) => t(`engine.packages.detail.${key}`, 'en'); @@ -72,8 +77,11 @@ const reply = (status: number, body: unknown) => let calls: { path: string; method: string }[]; -/** Serve the drafts read, the lifecycle doors and (for the page) the list door. */ -function stubWire(packages: unknown[] = []) { +/** + * Serve the drafts read, the lifecycle doors and (for the page) the list door. + * `answer` serves a door's own reply first (a refusal), when it returns one. + */ +function stubWire(packages: unknown[] = [], answer?: (path: string, method: string) => Response | undefined) { calls = []; vi.stubGlobal( 'fetch', @@ -81,6 +89,8 @@ function stubWire(packages: unknown[] = []) { const path = new URL(String(input), 'http://console.test').pathname; const method = (init?.method ?? 'GET').toUpperCase(); calls.push({ path, method }); + const own = answer?.(path, method); + if (own) return own; if (path.startsWith('/api/v1/meta/_drafts')) return reply(200, { success: true, data: { drafts: [] } }); if (path === '/api/v1/packages' && method === 'GET') return reply(200, { success: true, data: { packages } }); if (path === '/api/v1/cloud-connection/installed') return reply(404, {}); @@ -103,11 +113,16 @@ const settle = () => await new Promise((r) => setTimeout(r, 0)); }); -async function renderSheet(pkg: InstalledPackageRow) { - render( +async function renderSheet(pkg: InstalledPackageRow, language?: 'zh') { + const sheet = ( {}} onChanged={() => {}} /> - , + + ); + render( + language + ? {sheet} + : sheet, ); await screen.findByTestId('pkg-scope'); await settle(); @@ -220,3 +235,75 @@ describe('Installed Apps view — a marketplace suite\'s module row (objectstack expect(screen.queryByRole('button', { name: label('disable') })).toBeNull(); }); }); + +describe('PackageDetailSheet — a disable the server refuses for a dependent validation rule (objectstack#31003)', () => { + const CODE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + // The disable door's own sentence, as it spells it: the rule as + // package · object · rule, then the remedy. + const LISTING = 'com.acme.suite.custom · account · credit_cap (reads credit_limit)'; + const REMEDY = 'Switch off or change that rule first, or keep the package enabled.'; + const SENTENCE = + `Package '${MODULE_ID}' cannot be disabled: an active validation rule reads a field it adds through ` + + `objectExtensions, which the disable would withhold — ${LISTING}. ${REMEDY}`; + const refuseDisable = (status: number, code: string, message: string) => (path: string, method: string) => + method === 'PATCH' && path.endsWith('/disable') + ? reply(status, { success: false, error: { code, message } }) + : undefined; + + async function confirmDisable() { + fireEvent.click(screen.getByRole('button', { name: label('disable') })); + const dialog = await screen.findByTestId('pkg-detail-disable-dialog'); + fireEvent.click(within(dialog).getByTestId('pkg-detail-disable-confirm')); + await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['disable'])); + await settle(); + } + + it('the confirm dialog says a dependent validation rule can still refuse the disable', async () => { + stubWire(); + await renderSheet(moduleRow()); + fireEvent.click(screen.getByRole('button', { name: label('disable') })); + const dialog = await screen.findByTestId('pkg-detail-disable-dialog'); + expect(within(dialog).getByTestId('pkg-detail-disable-may-refuse')).toHaveTextContent(label('disableMayBeRefused')); + expect(within(dialog).getByText(label('disableDescription'))).toBeInTheDocument(); + }); + + it.each([ + ['en', undefined, label('disableRestricted')], + ['zh', 'zh' as const, ZH['engine.packages.detail.disableRestricted']], + ])('(%s) a 409 draws the heading in the reader\'s language and the server\'s whole sentence', async (_name, language, heading) => { + expect(heading, 'the catalogue carries the heading').toBeTruthy(); + stubWire([], refuseDisable(409, CODE, SENTENCE)); + await renderSheet(moduleRow(), language); + if (language) expect(heading).not.toBe(label('disableRestricted')); + await confirmDisable(); + + expect(await screen.findByTestId('pkg-detail-refusal-heading')).toHaveTextContent(heading!); + const text = screen.getByTestId('pkg-detail-refusal-text'); + // Whole, never cut to its first sentence: the rule listing and the remedy + // are both on screen, with no "More" in front of either. + expect(text).toHaveTextContent(LISTING); + expect(text).toHaveTextContent(REMEDY); + expect(text.textContent).toContain(SENTENCE); + expect(screen.queryByTestId('refusal-more')).toBeNull(); + // Still on offer: the package was not switched off. + expect(screen.getByRole('button', { name: language ? ZH['engine.packages.detail.disable'] : label('disable') })).toBeInTheDocument(); + }); + + it('CONTROL: another refusal code keeps the first-sentence drawing, with no heading', async () => { + stubWire([], refuseDisable(403, 'WRITABLE_PACKAGE_REQUIRED', 'Package is read-only. Disable it from its owner.')); + await renderSheet(moduleRow()); + await confirmDisable(); + expect(await screen.findByTestId('refusal-lead')).toHaveTextContent('Package is read-only.'); + expect(screen.getByTestId('refusal-more')).toBeInTheDocument(); + expect(screen.queryByTestId('pkg-detail-refusal-heading')).toBeNull(); + }); + + it('CONTROL: a disable with no dependent rule lands in one confirm, with no refusal drawn', async () => { + stubWire(); + await renderSheet(moduleRow()); + await confirmDisable(); + expect(await screen.findByText(label('disabled'))).toBeInTheDocument(); + expect(screen.queryByTestId('pkg-detail-refusal-heading')).toBeNull(); + expect(lifecycleCalls()).toEqual(['disable']); + }); +}); diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx index 617d1078962..6f906915a8a 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx @@ -188,6 +188,14 @@ type UncheckedPackageRow = Omit & { const API = '/api/v1/packages'; +/** + * The code `PATCH /packages/:id/disable` answers, with `409`, while a + * validation rule of an enabled package reads a field the package adds through + * `objectExtensions` (registered in the spec's error-code ledger). Its message + * names every such rule and the remedy; the refusal carries no `details`. + */ +const DISABLE_RESTRICTED_BY_VALIDATION_RULE = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'; + async function apiJson(path: string, init?: RequestInit): Promise { // objectstack#29119 — every write this page sends (publish, revert, discard, // delete, adopt, enable/disable, import) may change the package list, so the @@ -518,11 +526,14 @@ export function PackageDetailSheet({ const client = useMetadataClient(); const [busy, setBusy] = React.useState(null); // `details`: the lines a refusal carries under its sentence, in the server's - // own words (objectui#13160). + // own words (objectui#13160). `heading`: a console sentence, in the reader's + // language, drawn above a refusal that is then shown whole rather than cut + // to its first sentence (objectstack#31003). const [msg, setMsg] = React.useState<{ kind: 'ok' | 'err'; text: string; details?: readonly string[]; + heading?: string; } | null>(null); // ADR-0033 — pending DRAFT items bound to this package. AI-authored metadata // lands as drafts that the active-only browsers hide, so without this the @@ -629,6 +640,14 @@ export function PackageDetailSheet({ kind: 'err', text: e?.message ?? t('engine.packages.detail.actionFailed', locale), ...(e instanceof PackagePublishRefusal ? { details: e.reasons } : {}), + // objectstack#31003 — the disable door refuses while an enabled + // validation rule reads a field the package adds. The server's sentence + // names every such rule (package · object · rule) and then the remedy, + // so it is drawn whole under a heading in the reader's language: cut to + // its first sentence, the remedy sat behind "More". + ...(errorCodeIs(e, DISABLE_RESTRICTED_BY_VALIDATION_RULE) + ? { heading: t('engine.packages.detail.disableRestricted', locale) } + : {}), }); } finally { setBusy(null); @@ -1174,8 +1193,22 @@ export function PackageDetailSheet({ }`} > {/* A refusal leads with its first sentence; the rest of the - server's text is behind "More" (objectui#13332). */} - {msg.kind === 'err' ? : msg.text} + server's text is behind "More" (objectui#13332). One with a + heading is drawn whole under it (objectstack#31003). */} + {msg.kind === 'err' && msg.heading ? ( + <> +

+ {msg.heading} +

+

+ {msg.text} +

+ + ) : msg.kind === 'err' ? ( + + ) : ( + msg.text + )} {msg.details && msg.details.length > 0 && ( // Not capped: the sheet scrolls (`overflow-y-auto`), as it does // for the pending-drafts list above. `break-words` keeps a long @@ -1301,6 +1334,13 @@ export function PackageDetailSheet({ {t('engine.packages.detail.disableDescription', locale)} + {/* objectstack#31003 — the served `disableable` says what the + package allows, not whether a rule elsewhere still reads its + fields; the door answers that only when asked, so the dialog + says the disable can still be refused. */} +

+ {t('engine.packages.detail.disableMayBeRefused', locale)} +

diff --git a/ui/packages/i18n/src/namespaces/studio/en.ts b/ui/packages/i18n/src/namespaces/studio/en.ts index bf19cf0ce61..b026048bdbe 100644 --- a/ui/packages/i18n/src/namespaces/studio/en.ts +++ b/ui/packages/i18n/src/namespaces/studio/en.ts @@ -1933,6 +1933,8 @@ const STUDIO_EN: Record = { 'engine.packages.detail.disable': 'Disable', 'engine.packages.detail.disableTitle': 'Disable "{name}"?', '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.', + '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.', + '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.', 'engine.packages.detail.export': 'Export', 'engine.packages.detail.exporting': 'Exporting…', 'engine.packages.detail.deletePackage': 'Delete package', diff --git a/ui/packages/i18n/src/namespaces/studio/zh.ts b/ui/packages/i18n/src/namespaces/studio/zh.ts index 3fcc9cbfd0a..210c932216b 100644 --- a/ui/packages/i18n/src/namespaces/studio/zh.ts +++ b/ui/packages/i18n/src/namespaces/studio/zh.ts @@ -1745,6 +1745,8 @@ const STUDIO_ZH: Record = { 'engine.packages.detail.disable': '禁用', 'engine.packages.detail.disableTitle': '禁用「{name}」?', 'engine.packages.detail.disableDescription': '在重新启用之前,此软件包将对所有用户停止服务。不会删除任何记录,您可以随时重新启用。', + 'engine.packages.detail.disableMayBeRefused': '如果有生效中的验证规则读取此软件包添加的字段,禁用会被拒绝,拒绝信息会逐条列出这些规则。', + 'engine.packages.detail.disableRestricted': '有生效中的验证规则读取此软件包添加的字段,因此暂时无法禁用。请先停用或修改下方列出的每条规则,或保持此软件包启用。', 'engine.packages.detail.export': '导出', 'engine.packages.detail.exporting': '导出中…', 'engine.packages.detail.deletePackage': '删除软件包', From 80e7598ca416c12fba3e71d3f994910c042a833a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:09:58 +0000 Subject: [PATCH 2/4] test(app-shell): render the zh disable refusal through the sheet's zh labels Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwxPsNfCDtuhas4VDr --- .../PackagesPage.moduleDisable-28118.test.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx index 0609448f984..0d36c7c1682 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx @@ -250,8 +250,11 @@ describe('PackageDetailSheet — a disable the server refuses for a dependent va ? reply(status, { success: false, error: { code, message } }) : undefined; - async function confirmDisable() { - fireEvent.click(screen.getByRole('button', { name: label('disable') })); + /** The sheet's Disable, in the language it renders. */ + const disableLabel = (language?: 'zh') => (language ? ZH['engine.packages.detail.disable'] : label('disable')); + + async function confirmDisable(language?: 'zh') { + fireEvent.click(screen.getByRole('button', { name: disableLabel(language) })); const dialog = await screen.findByTestId('pkg-detail-disable-dialog'); fireEvent.click(within(dialog).getByTestId('pkg-detail-disable-confirm')); await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['disable'])); @@ -275,7 +278,7 @@ describe('PackageDetailSheet — a disable the server refuses for a dependent va stubWire([], refuseDisable(409, CODE, SENTENCE)); await renderSheet(moduleRow(), language); if (language) expect(heading).not.toBe(label('disableRestricted')); - await confirmDisable(); + await confirmDisable(language); expect(await screen.findByTestId('pkg-detail-refusal-heading')).toHaveTextContent(heading!); const text = screen.getByTestId('pkg-detail-refusal-text'); @@ -286,7 +289,7 @@ describe('PackageDetailSheet — a disable the server refuses for a dependent va expect(text.textContent).toContain(SENTENCE); expect(screen.queryByTestId('refusal-more')).toBeNull(); // Still on offer: the package was not switched off. - expect(screen.getByRole('button', { name: language ? ZH['engine.packages.detail.disable'] : label('disable') })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: disableLabel(language) })).toBeInTheDocument(); }); it('CONTROL: another refusal code keeps the first-sentence drawing, with no heading', async () => { From b59f9b0d351fe57ca2726ae286a5923995929e18 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:10:33 +0000 Subject: [PATCH 3/4] chore(changeset): app-shell and i18n patch for the disable refusal drawing Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwxPsNfCDtuhas4VDr --- .../31003-package-disable-dependent-rule-refusal.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/31003-package-disable-dependent-rule-refusal.md diff --git a/.changeset/31003-package-disable-dependent-rule-refusal.md b/.changeset/31003-package-disable-dependent-rule-refusal.md new file mode 100644 index 00000000000..c2dbd3d8167 --- /dev/null +++ b/.changeset/31003-package-disable-dependent-rule-refusal.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +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 + +Clause-②: yes (widening) +Metadata-protocol: none + +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`. From 0b6d41bd1fce3f284632559bd655439e10e3e438 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:35:30 +0000 Subject: [PATCH 4/4] test(app-shell): warm the lazily loaded lint and formula modules in the module-disable suite Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01L9qibwxPsNfCDtuhas4VDr --- .../metadata-admin/PackagesPage.moduleDisable-28118.test.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx index 0d36c7c1682..0d7f13204e7 100644 --- a/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx +++ b/ui/packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx @@ -37,6 +37,11 @@ import { PackageDetailSheet, PackagesPage, type InstalledPackageRow } from './Pa import { t } from './i18n'; import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config'; import { loadStudioCatalogue } from '@object-ui/i18n/namespaces/studio'; +// The sheet's package form reaches the CEL authoring helpers, which load these +// two lazily; warmed here so their first load is paid at import, not inside a +// test's bounded wait. +import '@objectstack/formula'; +import '@objectstack/lint'; // The zh catalogue of the designer strings is fetched on first use; loaded here // so a zh render answers in zh from its first paint.