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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/31003-package-disable-dependent-rule-refusal.md
Original file line number Diff line number Diff line change
@@ -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`.
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@ 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 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.
const ZH = await loadStudioCatalogue('zh');

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

Expand Down Expand Up @@ -72,15 +82,20 @@ 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',
vi.fn(async (input: unknown, init?: { method?: string }) => {
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, {});
Expand All @@ -103,11 +118,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 = (
<MemoryRouter>
<PackageDetailSheet pkg={pkg} open onOpenChange={() => {}} onChanged={() => {}} />
</MemoryRouter>,
</MemoryRouter>
);
render(
language
? <I18nProvider config={{ defaultLanguage: language, detectBrowserLanguage: false }}>{sheet}</I18nProvider>
: sheet,
);
await screen.findByTestId('pkg-scope');
await settle();
Expand Down Expand Up @@ -220,3 +240,78 @@ 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;

/** 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']));
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(language);

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: disableLabel(language) })).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']);
});
});
46 changes: 43 additions & 3 deletions ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,14 @@ type UncheckedPackageRow = Omit<InstalledPackageRow, 'source' | 'writable'> & {

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<T>(path: string, init?: RequestInit): Promise<T> {
// objectstack#29119 — every write this page sends (publish, revert, discard,
// delete, adopt, enable/disable, import) may change the package list, so the
Expand Down Expand Up @@ -518,11 +526,14 @@ export function PackageDetailSheet({
const client = useMetadataClient();
const [busy, setBusy] = React.useState<string | null>(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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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' ? <RefusalMessage text={msg.text} /> : 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 ? (
<>
<p className="font-medium" data-testid="pkg-detail-refusal-heading">
{msg.heading}
</p>
<p className="mt-1 whitespace-pre-wrap break-words select-text" data-testid="pkg-detail-refusal-text">
{msg.text}
</p>
</>
) : msg.kind === 'err' ? (
<RefusalMessage text={msg.text} />
) : (
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
Expand Down Expand Up @@ -1301,6 +1334,13 @@ export function PackageDetailSheet({
<AlertDialogDescription>
{t('engine.packages.detail.disableDescription', locale)}
</AlertDialogDescription>
{/* 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. */}
<p className="text-sm text-muted-foreground" data-testid="pkg-detail-disable-may-refuse">
{t('engine.packages.detail.disableMayBeRefused', locale)}
</p>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="pkg-detail-disable-cancel">
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1933,6 +1933,8 @@ const STUDIO_EN: Record<string, string> = {
'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',
Expand Down
2 changes: 2 additions & 0 deletions ui/packages/i18n/src/namespaces/studio/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1745,6 +1745,8 @@ const STUDIO_ZH: Record<string, string> = {
'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': '删除软件包',
Expand Down
Loading