Skip to content

Commit 0afb1b2

Browse files
feat(app-shell): offer Disable / Enable on a suite's module rows from the served disableable verdict (#28903)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbELzM8mf3sjACy6nzVrqj1y)_ Fixes #28118 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:软件包详情面板的「禁用 / 启用」按钮改为只看服务端下发的 `disableable` 判定(不再看 `writable`)。套件里 `type: 'module'` 的模块包(对创作只读、但服务端允许禁用)现在会出现「禁用」,走原有确认对话框;禁用后出现「启用」。编辑、发布、还原、复制、收编、删除仍然只看 `writable`,在模块行上继续隐藏。模块行的只读提示改为说明「不能编辑或删除,但可以禁用并重新启用」(新增两个 studio 文案,en / zh)。市场手册补充了这个按钮和 `disableable` 键。 - **为什么改**:#28117 让管理员可以禁用已安装应用的模块,#28318 在 `GET /packages` 上下发了 `disableable`。裁决「最小做法」:不加新页面,就在现有的软件包页给开关。 - **风险与回滚**:按钮只在服务端说 `disableable: true` 时出现,没有新的写入路径,也没有新权限(仍是 `manage_metadata`)。可写包的服务端判定同样是 `disableable: true`,所以它们的「禁用」不变。缺少该键的行不会出现「禁用」(失败即关闭)。落地顺序受 objectstack-ai/hotcrm#2383 约束:在它落地前本 PR 保持草稿,不对客户可见。回滚就是 revert 本 PR。 - **先看**:`ui/packages/app-shell/src/views/metadata-admin/PackagesPage.tsx`(`disableable` 读取、按钮门槛、只读提示的分支)、`PackagesPage.moduleDisable-28118.test.tsx`(新测试)、`content/docs/deployment/marketplace-handbook.mdx`(手册两处)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/views/metadata-admin/PackagesPage.moduleDisable-28118.test.tsx`,预期 10 个用例全绿:模块行有「禁用」且需确认、其余写操作隐藏、无判定的只读行没有「禁用」、已禁用的模块行有「启用」、Installed Apps 视图里模块是单独一行。 - **需要你做的**:无,席位在 objectstack-ai/hotcrm#2383 落地后落地。 ## What changes - **Disable reads the served verdict.** `PackageDetailSheet` gates its Disable / Enable button on the row's `disableable` (the disable door's own verdict, `package-api-assembled.zod.ts` `SERVED_ROW_VERDICTS`, served by `GET /api/v1/packages` and `GET /api/v1/packages/:id`), not on `writable`. It is read the way the sheet reads `writable`: anything but `true` offers no Disable. ⛔ Nothing is re-derived from `manifest.type`, `scope` or `writable`. Enable is still offered on any disabled package, as before (the enable door has no writability gate). - **Every other write stays on `writable`.** Edit, Publish, Revert, Duplicate, Adopt loose items and Delete are unchanged, so a module row (`writable: false`, `disableable: true`) offers only Disable / Enable beside the reads. - **The read-only note.** A read-only row that is disableable no longer says it "cannot be edited, disabled or deleted": two studio keys, `engine.packages.detail.readOnlyDisableable` and `engine.packages.detail.codeReadOnlyDisableable` (en, zh), say it cannot be edited or deleted but can be disabled and enabled again. The confirm dialog's consequence sentence (`disableDescription`) already reads right for a module and is unchanged. - **Type.** `InstalledPackageRow` gains `disableable?: InstalledPackageAtEitherStage['disableable']`, taken from the spec's served row. It is optional on the row type so that a row without the key types and renders as "not disableable". - **Docs (doc rider 6540426908).** `content/docs/deployment/marketplace-handbook.mdx`: step 7 says each package of a suite is its own row in Installed Apps and the sheet offers Disable where the runtime allows it; "Disabling a module" names the Console path and the `disableable` key. Where module rows appear, measured before coding (reported on the card): install-local registers every package of the artifact with `source: 'marketplace'`, so a marketplace suite's modules are their own rows in **Setup → Installed Apps** (`?source=marketplace`). A suite booted from code has `source: 'code'`; its modules are on the unfiltered **Setup → Packages** page, with the same button. ## Tests - New `PackagesPage.moduleDisable-28118.test.tsx` (10 cases, real `PackageDetailSheet` and real `PackagesPage`): - (1) a module row with `disableable: true` shows Disable; it opens the confirm with the consequence copy, sends nothing until confirmed, then sends the one `PATCH /api/v1/packages/:id/disable`; - (2) every other write action is hidden on it; its read-only note is the disableable one, scope-less and project-scoped; - (3) `disableable: false` and a row with no key offer no Disable and keep the plain read-only note; - (4) a disabled module row shows Enable, which runs at once with no dialog; - (5) a non-module row served `disableable: true` offers Disable: the verdict decides, not `manifest.type`; - the Installed Apps view lists a marketplace module as its own row and its sheet offers Disable; control: the suite's app row (`disableable: false`) offers none. - Existing fixtures in `readOnlyActions-13157`, `readOnlyReasonAndDisableConfirm-27521`, `adoptLooseItems-13339` and `confirmFlows-11784` now carry `disableable` as the server serves it for a non-module package (equal to `writable`). - Ablation, once, on commit `2d45950f`: `node scripts/ablation-replace.mjs` put the gate back to `writable || !enabled` (anchor 1 → 0, blob `93d7556c` → `7ebf253c`). The new file went red, 3 failed / 7 passed: (1), (5) and the Installed Apps case. Restored blob `93d7556c` equals HEAD and `git diff HEAD` is empty. Case (4) and the controls stay green both ways, as expected: Enable does not change. ## Acceptance notes - `disableable` is optional on the row type and the list does not refuse a row without it, unlike `source` and `writable`. A missing key fails closed (no Disable, the door's own answer) instead of refusing the whole list. Making it a refused key would touch every page-level fixture; noted, not done. - The landing waits on objectstack-ai/hotcrm#2383 (the card's `Blocked-by:`), per the ordering addendum 6523692915. The PR stays draft until then. - A code-booted suite's modules are not in Installed Apps (they are `source: 'code'`); that is the existing Source filter's meaning and is unchanged here. ## Verification <details><summary>Local runs</summary> - `pnpm --dir ui exec vitest run` on the 5 touched test files: 5 files, 55 tests green. - The 25 Packages page, package source and metadata-admin i18n test files plus `MarketplaceRowActions.installedAppsPins`: 25 files, 292 tests green. - `pnpm --dir ui exec vitest run packages/i18n/`: 94 files, 1438 passed, 13 skipped. - `check:i18n-keys`, `check:i18n-drift`, `check:i18n-dead-keys`: exit 0. - ESLint on the 8 changed ts/tsx files: exit 0, 0 errors, 15 warnings, none on a changed line. - `turbo run type-check --filter=@object-ui/app-shell --filter=@object-ui/i18n` (each runs `tsc --noEmit` and `tsc -p tsconfig.test.json`, so the tests are compiled): 66 tasks, exit 0. - `pnpm --dir ui exec vitest run scripts/`: 169 files passed, 2 skipped; 4900 tests passed. `check:new-line-citations`: 0 new. `node scripts/check-sdui-manifest.mjs`: exit 0 (in the derived set). - `dispatch-gates --commands` at `2d45950f`: 66 derived, 66 run, all exit 0; `--ran` reconciles 66 of 66 with 0 NOT-MEASURED. `check:skill-examples` first refused with exit 3 (`@objectstack/client-react` declarations not built); after `pnpm --filter @objectstack/client-react build` it ran: 280 prose examples type-check, exit 0. - Main merged at `4775dfce` (4 commits; two touch app-shell and the studio en / zh catalogues, no conflict). Re-run on that head: the app-shell + i18n type-check (66 tasks, exit 0), the 25 Packages page files plus `packages/i18n/` (119 files, 1730 passed, 13 skipped), the three i18n checks and `check-sdui-manifest` (exit 0). The derived-gate sweep above is from `2d45950f`, before the merge; the merge adds no file to this diff. - Dogfood battery: n/a, no `!` in the title and no `(narrowing)` arm. - NOT MEASURED: a live Console against a marketplace-installed suite. The measurement is from source and the landed server tests (`packages-disable-app-module.test.ts` pins the module row's `source` per install path; `packages-disableable-verdict.test.ts` pins the served verdict). </details> _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSW86XpeUhXzbQFo4Z)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent b0ef25f commit 0afb1b2

10 files changed

Lines changed: 286 additions & 15 deletions
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 Packages page offers Disable / Enable on a suite's module rows, from the server's `disableable` verdict
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
The package detail sheet now offers **Disable** where the runtime's disable door accepts the package: the row's served `disableable` key on `GET /api/v1/packages`, never re-derived from `manifest.type`, `scope` or `writable`. A `type: 'module'` package of an installed app is `disableable` and not `writable`, so its row (listed in **Setup → Installed Apps** when the suite came from the marketplace, and in **Setup → Packages** when it was booted from code) now shows **Disable**, behind the same confirm dialog, and **Enable** once it is disabled. Every other write action (Edit, Publish, Revert, Duplicate, Adopt loose items, Delete) stays hidden on it, since it is still read-only for authoring, and its read-only note says it can be disabled. A row that carries no `disableable: true` offers no Disable; Enable is still offered on any disabled package.

‎content/docs/deployment/marketplace-handbook.mdx‎

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -218,10 +218,13 @@ the detail.
218218
`POST /api/v1/marketplace/install-local/:manifestId/reseed-sample-data` and
219219
`…/purge-sample-data`, both requiring `manage_metadata`.
220220
7. **Enable and disable.** **Setup → Installed Apps** lists the apps installed
221-
from the marketplace. An admin with `manage_metadata` may disable a writable
222-
package, and a **module of an installed app**: a `type: 'module'` package
223-
that declares an installed `type: 'app'` package in its
224-
`manifest.dependencies` ([Disabling a module](#disabling-a-module) below).
221+
from the marketplace, each package of a suite as its own row. An admin with
222+
`manage_metadata` may disable a writable package, and a **module of an
223+
installed app**: a `type: 'module'` package that declares an installed
224+
`type: 'app'` package in its `manifest.dependencies`
225+
([Disabling a module](#disabling-a-module) below). The package's detail
226+
sheet offers **Disable** where the runtime allows it and **Enable** on any
227+
disabled package.
225228
For every other package provided by code or by an installed app (the app
226229
itself, a plugin, a platform package), the runtime answers
227230
`PATCH /api/v1/packages/:id/disable` with `422 WRITABLE_PACKAGE_REQUIRED`.
@@ -267,7 +270,13 @@ An app ships extras in one of two shapes:
267270

268271
A customer who does not use one of your modules can switch it off, per
269272
environment, with `PATCH /api/v1/packages/:id/disable`, and back on with
270-
`PATCH /api/v1/packages/:id/enable`. The module qualifies when its manifest
273+
`PATCH /api/v1/packages/:id/enable`. In the Console, the admin opens the
274+
module's row in **Setup → Installed Apps** (or **Setup → Packages** for a suite
275+
booted from code) and presses **Disable**, then confirms. The row's
276+
`disableable` key on `GET /api/v1/packages` is the runtime's own answer to
277+
whether the disable door accepts the package, and the Console offers
278+
**Disable** from it alone; `writable` stays `false` for a module, so its edit,
279+
publish and delete actions stay hidden. The module qualifies when its manifest
271280
says `type: 'module'` and declares your app in `dependencies`, the way every
272281
module of a suite already does:
273282

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const PKG: InstalledPackageRow = {
3838
status: 'installed',
3939
source: 'authored',
4040
writable: true,
41+
// A writable base is disableable too (objectstack#28318): the sheet offers
42+
// Disable from this verdict alone.
43+
disableable: true,
4144
};
4245
const DRAFTS = [
4346
{ type: 'object', name: 'acme_lead' },

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ const PKG: InstalledPackageRow = {
4343
// refuses a row without either, each by its own name (objectui#12790).
4444
source: 'authored',
4545
writable: true,
46+
// The disable door's verdict (objectstack#28318), `true` for a writable base.
47+
disableable: true,
4648
};
4749
const DRAFTS = [
4850
{ type: 'object', name: 'acme_lead' },
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28118 — a suite's `type: 'module'` row offers Disable / Enable
5+
* from the disable door's served verdict, `disableable` (objectstack#28318),
6+
* and nothing else it cannot author.
7+
*
8+
* A module of an installed app is served `writable: false` (read-only for
9+
* authoring) and `disableable: true` (the disable door accepts it). Pinned
10+
* through the real `PackageDetailSheet`, and through the real page in the
11+
* Installed Apps view (`?source=marketplace`), where a marketplace-installed
12+
* suite's modules are listed as their own rows:
13+
* (1) a module row with the verdict shows Disable, which confirms with the
14+
* existing consequence copy and sends the one `PATCH …/disable`;
15+
* (2) every other write action stays hidden on it, and its read-only reason
16+
* says it can be disabled;
17+
* (3) a read-only row without the verdict offers no Disable (served `false`,
18+
* or absent: anything but `true`);
19+
* (4) a disabled module row shows Enable, which runs at once;
20+
* (5) the verdict is read, never re-derived: `manifest.type` alone moves
21+
* nothing.
22+
*/
23+
24+
import '@testing-library/jest-dom/vitest';
25+
import * as React from 'react';
26+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
27+
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react';
28+
import { MemoryRouter } from 'react-router-dom';
29+
import { I18nProvider } from '@object-ui/i18n';
30+
31+
vi.mock('@object-ui/auth', async (importOriginal) => ({
32+
...(await importOriginal<Record<string, unknown>>()),
33+
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
34+
}));
35+
36+
import { PackageDetailSheet, PackagesPage, type InstalledPackageRow } from './PackagesPage';
37+
import { t } from './i18n';
38+
import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../runtime-config';
39+
40+
const label = (key: string) => t(`engine.packages.detail.${key}`, 'en');
41+
42+
/** The write actions a writable base offers besides Disable. */
43+
const OTHER_WRITES = ['edit', 'publish', 'revert', 'duplicate', 'adoptOrphans', 'deletePackage'];
44+
45+
const MODULE_ID = 'com.acme.suite.orders';
46+
47+
function moduleRow(
48+
extra: { enabled?: boolean; scope?: 'project'; disableable?: unknown; type?: string } = {},
49+
): InstalledPackageRow {
50+
return {
51+
manifest: {
52+
id: MODULE_ID,
53+
name: 'Acme Suite Orders',
54+
version: '1.0.0',
55+
type: extra.type ?? 'module',
56+
...(extra.scope ? { scope: extra.scope } : {}),
57+
},
58+
enabled: extra.enabled ?? true,
59+
source: 'marketplace',
60+
writable: false,
61+
// `disableable: undefined` stands for a row that carries no such key.
62+
...(!('disableable' in extra)
63+
? { disableable: true }
64+
: extra.disableable === undefined
65+
? {}
66+
: { disableable: extra.disableable as boolean }),
67+
};
68+
}
69+
70+
const reply = (status: number, body: unknown) =>
71+
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
72+
73+
let calls: { path: string; method: string }[];
74+
75+
/** Serve the drafts read, the lifecycle doors and (for the page) the list door. */
76+
function stubWire(packages: unknown[] = []) {
77+
calls = [];
78+
vi.stubGlobal(
79+
'fetch',
80+
vi.fn(async (input: unknown, init?: { method?: string }) => {
81+
const path = new URL(String(input), 'http://console.test').pathname;
82+
const method = (init?.method ?? 'GET').toUpperCase();
83+
calls.push({ path, method });
84+
if (path.startsWith('/api/v1/meta/_drafts')) return reply(200, { success: true, data: { drafts: [] } });
85+
if (path === '/api/v1/packages' && method === 'GET') return reply(200, { success: true, data: { packages } });
86+
if (path === '/api/v1/cloud-connection/installed') return reply(404, {});
87+
if (path === '/api/v1/marketplace/install-local' && method === 'GET') {
88+
return reply(200, { success: true, data: { items: [] } });
89+
}
90+
return reply(200, { success: true, data: {} });
91+
}),
92+
);
93+
}
94+
95+
/** The lifecycle PATCHes sent: `enable` / `disable`. */
96+
const lifecycleCalls = () =>
97+
calls
98+
.filter((c) => c.method === 'PATCH' && /\/(enable|disable)$/.test(c.path))
99+
.map((c) => c.path.split('/').pop());
100+
101+
const settle = () =>
102+
act(async () => {
103+
await new Promise((r) => setTimeout(r, 0));
104+
});
105+
106+
async function renderSheet(pkg: InstalledPackageRow) {
107+
render(
108+
<MemoryRouter>
109+
<PackageDetailSheet pkg={pkg} open onOpenChange={() => {}} onChanged={() => {}} />
110+
</MemoryRouter>,
111+
);
112+
await screen.findByTestId('pkg-scope');
113+
await settle();
114+
}
115+
116+
const offered = (keys: string[]) => keys.filter((k) => screen.queryByRole('button', { name: label(k) }) !== null);
117+
118+
afterEach(() => {
119+
cleanup();
120+
vi.unstubAllGlobals();
121+
resetRuntimeConfigForTesting();
122+
});
123+
124+
describe('PackageDetailSheet — a module row with the served verdict (objectstack#28118)', () => {
125+
beforeEach(() => stubWire());
126+
127+
it('(1) shows Disable, which opens the confirm and sends nothing until it is confirmed', async () => {
128+
await renderSheet(moduleRow());
129+
fireEvent.click(screen.getByRole('button', { name: label('disable') }));
130+
const dialog = await screen.findByTestId('pkg-detail-disable-dialog');
131+
expect(within(dialog).getByText(/Acme Suite Orders/)).toBeInTheDocument();
132+
expect(within(dialog).getByText(label('disableDescription'))).toBeInTheDocument();
133+
expect(lifecycleCalls()).toEqual([]);
134+
135+
fireEvent.click(within(dialog).getByTestId('pkg-detail-disable-confirm'));
136+
await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['disable']));
137+
expect(calls.find((c) => c.method === 'PATCH')?.path).toBe(`/api/v1/packages/${MODULE_ID}/disable`);
138+
});
139+
140+
it('(2) keeps every other write action hidden: the module stays read-only for authoring', async () => {
141+
await renderSheet(moduleRow());
142+
expect(offered(OTHER_WRITES)).toEqual([]);
143+
expect(screen.getByTestId('pkg-scope')).toHaveTextContent(t('engine.packages.scope.readOnly', 'en'));
144+
});
145+
146+
it.each([
147+
['a scope-less module (installed from the marketplace)', moduleRow(), 'readOnlyDisableable'],
148+
['a project-scoped module (booted from code)', moduleRow({ scope: 'project' }), 'codeReadOnlyDisableable'],
149+
])('(2) %s states that it is read-only and can be disabled', async (_name, pkg, key) => {
150+
await renderSheet(pkg);
151+
expect(screen.getByTestId('pkg-detail-readonly-reason')).toHaveTextContent(label(key));
152+
});
153+
154+
it('(4) a disabled module row shows Enable, which runs at once with no dialog', async () => {
155+
await renderSheet(moduleRow({ enabled: false }));
156+
expect(screen.queryByRole('button', { name: label('disable') })).toBeNull();
157+
fireEvent.click(screen.getByRole('button', { name: label('enable') }));
158+
await vi.waitFor(() => expect(lifecycleCalls()).toEqual(['enable']));
159+
expect(screen.queryByTestId('pkg-detail-disable-dialog')).toBeNull();
160+
expect(offered(OTHER_WRITES)).toEqual([]);
161+
});
162+
});
163+
164+
describe('PackageDetailSheet — a read-only row without the verdict (objectstack#28118)', () => {
165+
beforeEach(() => stubWire());
166+
167+
it.each([
168+
['served disableable: false', moduleRow({ disableable: false })],
169+
['no disableable key at all', moduleRow({ disableable: undefined })],
170+
])('(3) %s offers no Disable and keeps the plain read-only reason', async (_name, pkg) => {
171+
await renderSheet(pkg);
172+
expect(screen.queryByRole('button', { name: label('disable') })).toBeNull();
173+
expect(screen.queryByRole('button', { name: label('enable') })).toBeNull();
174+
expect(screen.getByTestId('pkg-detail-readonly-reason')).toHaveTextContent(label('readOnly'));
175+
});
176+
177+
it('(5) the verdict decides, not manifest.type: a non-module row served disableable offers Disable', async () => {
178+
await renderSheet(moduleRow({ type: 'app' }));
179+
expect(screen.getByRole('button', { name: label('disable') })).toBeInTheDocument();
180+
expect(offered(OTHER_WRITES)).toEqual([]);
181+
});
182+
});
183+
184+
describe('Installed Apps view — a marketplace suite\'s module row (objectstack#28118)', () => {
185+
const APP_ROW: InstalledPackageRow = {
186+
manifest: { id: 'com.acme.suite', name: 'Acme Suite', version: '1.0.0', type: 'app' },
187+
enabled: true,
188+
source: 'marketplace',
189+
writable: false,
190+
disableable: false,
191+
};
192+
193+
async function renderInstalledApps() {
194+
resetRuntimeConfigForTesting();
195+
vi.stubGlobal('fetch', vi.fn(async () => reply(200, { features: { installLocal: true } })));
196+
await initRuntimeConfig();
197+
stubWire([APP_ROW, moduleRow()]);
198+
render(
199+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
200+
<MemoryRouter initialEntries={['/apps/setup/component/developer/packages?source=marketplace']}>
201+
<PackagesPage />
202+
</MemoryRouter>
203+
</I18nProvider>,
204+
);
205+
}
206+
207+
it('lists the module as its own row, and its sheet offers Disable', async () => {
208+
await renderInstalledApps();
209+
fireEvent.click(await screen.findByTestId(`pkg-row-${MODULE_ID}`));
210+
expect(await screen.findByRole('button', { name: label('disable') })).toBeInTheDocument();
211+
expect(offered(OTHER_WRITES)).toEqual([]);
212+
});
213+
214+
it('CONTROL: the suite\'s app row, served disableable: false, offers no Disable', async () => {
215+
await renderInstalledApps();
216+
fireEvent.click(await screen.findByTestId('pkg-row-com.acme.suite'));
217+
// The sheet is open once its read actions are drawn.
218+
await screen.findByRole('button', { name: label('viewInfo') });
219+
await settle();
220+
expect(screen.queryByRole('button', { name: label('disable') })).toBeNull();
221+
});
222+
});

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,9 @@ function row(
5454
enabled: extra.enabled ?? true,
5555
source: 'code',
5656
writable: writable as boolean,
57+
// The disable door's verdict as the server serves it for a non-module
58+
// package (objectstack#28318): disableable exactly when writable.
59+
disableable: writable === true,
5760
};
5861
}
5962

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,9 @@ function row(
4040
enabled: extra.enabled ?? true,
4141
source: 'code',
4242
writable,
43+
// The disable door's verdict as the server serves it for a non-module
44+
// package (objectstack#28318): disableable exactly when writable.
45+
disableable: writable,
4346
};
4447
}
4548

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

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -158,8 +158,15 @@ export interface PackageManifestRow {
158158
*
159159
* The list reads both at load (`UncheckedPackageRow` below is the wire before
160160
* that read) and refuses a row by name when either is missing.
161+
*
162+
* `disableable` is the disable door's own verdict, served beside `writable`
163+
* (objectstack#28318): the detail sheet offers Disable from it alone, never
164+
* from `writable`, `manifest.type` or `scope` — a module of an installed app
165+
* is `disableable` and not `writable`. Read like `writable` in the sheet:
166+
* anything but `true` offers no Disable, the answer the door would give.
161167
*/
162168
export interface InstalledPackageRow extends Pick<InstalledPackageAtEitherStage, 'source' | 'writable'> {
169+
disableable?: InstalledPackageAtEitherStage['disableable'];
163170
manifest: PackageManifestRow;
164171
status?: string;
165172
enabled?: boolean;
@@ -578,11 +585,17 @@ export function PackageDetailSheet({
578585
// Scope badge above draws Writable: the same served verdict, the row's
579586
// top-level `writable` (objectstack#14375), read the same way (anything but
580587
// `true` is never writable). On a read-only package the server refuses
581-
// Edit, Publish, Revert, Disable and Delete (`WRITABLE_PACKAGE_REQUIRED`)
582-
// and Duplicate (`DUPLICATE_SOURCE_NOT_A_BASE`: its source must be a
583-
// writable base); Adopt orphans would bind loose items into a package the
584-
// org cannot author. So none of them is offered there. The reads stay.
588+
// Edit, Publish, Revert and Delete (`WRITABLE_PACKAGE_REQUIRED`) and
589+
// Duplicate (`DUPLICATE_SOURCE_NOT_A_BASE`: its source must be a writable
590+
// base); Adopt orphans would bind loose items into a package the org cannot
591+
// author. So none of them is offered there. The reads stay. Disable has its
592+
// own served verdict, `disableable`, below.
585593
const writable = pkg.writable === true;
594+
// objectstack#28118 — Disable is offered from the disable door's own served
595+
// verdict (`disableable`, objectstack#28318), never re-derived here: it is
596+
// `true` for a writable base and for a module of an installed app, which
597+
// stays read-only for every other write action above and below.
598+
const disableable = pkg.disableable === true;
586599
// The name the delete dialog asks the author to type: the one the sheet's
587600
// title shows.
588601
const displayName = pkg.manifest.name || id;
@@ -1025,8 +1038,8 @@ export function PackageDetailSheet({
10251038
) : pkg.writable === false ? (
10261039
<p className="text-sm text-muted-foreground" data-testid="pkg-detail-readonly-reason">
10271040
{pkg.manifest.scope === 'project'
1028-
? t('engine.packages.detail.codeReadOnly', locale)
1029-
: t('engine.packages.detail.readOnly', locale)}
1041+
? t(disableable ? 'engine.packages.detail.codeReadOnlyDisableable' : 'engine.packages.detail.codeReadOnly', locale)
1042+
: t(disableable ? 'engine.packages.detail.readOnlyDisableable' : 'engine.packages.detail.readOnly', locale)}
10301043
</p>
10311044
) : null}
10321045
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
@@ -1055,10 +1068,11 @@ export function PackageDetailSheet({
10551068
{t('engine.packages.detail.revert', locale)}
10561069
</Button>
10571070
)}
1058-
{/* Disable is refused on a read-only package; Enable is not (the
1059-
server's enable door has no writability gate), so a disabled
1060-
read-only package can still be switched back on. */}
1061-
{(writable || !enabled) && (
1071+
{/* Disable is offered where the disable door accepts it, the
1072+
served `disableable`; Enable is not refused anywhere (the
1073+
server's enable door has no writability gate), so any disabled
1074+
package can still be switched back on. */}
1075+
{(disableable || !enabled) && (
10621076
<Button
10631077
size="sm"
10641078
variant="outline"

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1881,6 +1881,8 @@ const STUDIO_EN: Record<string, string> = {
18811881
'engine.packages.detail.kernelReadOnly': 'This package is part of the platform. It cannot be edited, disabled or deleted.',
18821882
'engine.packages.detail.codeReadOnly': 'This package is loaded from code, so it is read-only here: it cannot be edited, disabled or deleted in the console. Change it in its source and deploy again.',
18831883
'engine.packages.detail.readOnly': 'This package is read-only here: it cannot be edited, disabled or deleted in the console.',
1884+
'engine.packages.detail.codeReadOnlyDisableable': 'This package is loaded from code, so it is read-only here: it cannot be edited or deleted in the console, but you can disable it and enable it again. Change it in its source and deploy again.',
1885+
'engine.packages.detail.readOnlyDisableable': 'This package is read-only here: it cannot be edited or deleted in the console, but you can disable it and enable it again.',
18841886
'engine.packages.detail.actions': 'Actions',
18851887
'engine.packages.detail.edit': 'Edit',
18861888
'engine.packages.edit.title': 'Edit package',

0 commit comments

Comments
 (0)