Skip to content

Commit 7e200b4

Browse files
fix(studio): a read-only package's flow Enabled switch is drawn muted with a lock (#27376)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ836VkJMbRirYcP4FDCFmjrE)_ Part of #27356 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:Studio 自动化页里,只读(代码)包的流程「启用」开关不再画成绿色的可操作开关,改为灰色轨道加一把锁;开/关的位置与文字照旧如实显示。 - 为什么改:HotCRM 18.2.0 浏览器走查(hotcrm#2320 Studio S10)发现只读包的开关绿着,看起来能点,其实点不动。 - 风险与回滚:只改这一个开关的样式类与一个图标;可写包外观和行为不变;回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx`(`flowSwitchEl` 里的 `track` 与 `Lock`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/views/studio-design/StudioDesignSurface.readOnlyFlowSwitch-27356.test.tsx`,预期 4 个用例全绿。 - 需要你做的:无,席位落地。 ## What changes Row 21 of #27356 (hotcrm#2320, Studio report finding S10): "A read-only package's flow Enabled switch is drawn on and green, like a live control." In the Automations pillar's flow header (`flowSwitchEl` in `StudioDesignSurface.tsx`), on a read-only package: - The track is muted: `bg-muted-foreground/60` when the flow is on, `bg-muted-foreground/25` when it is off, instead of `bg-emerald-500` / `bg-muted-foreground/40`. - A lock icon (`Lock`, `aria-hidden`) sits between the track and the word. - The button drops its hover fill and the `disabled:opacity-50` fade, and reads in `text-muted-foreground`, so the lock and the word stay legible. - Unchanged: the thumb position, the word (Enabled / Disabled), `aria-checked`, `disabled`, and the read-only reason on the wrapper (objectui#12303). A writable package's switch is unchanged: green track when on, hover fill, operable. ## Why The switch was already disabled on a read-only package (objectui#11124), but a disabled switch drawn green at half opacity still reads as a live control that is on. The muted track plus the lock says "on, and locked" in the same place the read-only chip already says why. ## Tests `ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.readOnlyFlowSwitch-27356.test.tsx` (4 tests, the real `AutomationsPillar` and the registered `FlowPreview`, harness copied from the objectui#11124 file): - Read-only, enabled flow: disabled, `aria-checked="true"`, the track has no `bg-emerald-500` and carries the muted on-class, the thumb is at on, a hidden lock is present, no hover fill, and a click saves nothing. - Read-only, disabled flow: `aria-checked="false"`, the fainter track, the thumb at off, the lock. - CONTROL writable, enabled flow: green track, no lock, a click saves `status: 'obsolete'`. - CONTROL writable, disabled flow: the grey track it always had, no lock. <details><summary>Ablation (one, on the read-only on-track)</summary> `node scripts/ablation-replace.mjs --anchor "? 'bg-muted-foreground/60'" --replacement "? 'bg-emerald-500'"` on `StudioDesignSurface.tsx` (anchor 1 → 0, blob `112c2439faa1` → `30f9154353a7`): the new file goes 1 failed / 3 passed, `expected 'relative inline-flex h-5 w-9 shrink-0…' not to contain 'bg-emerald-500'`. Restored: blob after restore = blob at HEAD = `112c2439faa1`, `git diff HEAD` empty. </details> ## Acceptance notes - Setup's Packaged Automation toggle (row 3 of #27356) has the same look problem; it is seat 2's row and is not touched here. - The first commit on this branch carries no `Claude-Session` / `Co-authored-by` trailer pair (a WIP commit pushed before the closure build); later commits carry it. History is not rewritten. ## Verification All on head `a24979d2` (merge base `28872e68`), under the shared verification lock. - New file: 4 / 4 passed. The eight existing files that drive the same switch (`automationsReadOnly-11124`, `automationsReadOnlySelect-11546`, `autosaveInFlight-11204`, `autosaveSwitch-11232`, `flowRegistration-12349`, `interfacesReadOnly-11136`, `packageLessFlows-11553`, `AutomationsPillar.narrowRow-11795`) plus the new one: 9 files, 76 / 76 passed. - `pnpm --filter @object-ui/app-shell run type-check` (both `tsc` programs): exit 0. - `pnpm --dir ui run check:new-line-citations`: 0 new citations, exit 0. - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`: 34 families derived, all 34 run, all exit 0; `--ran` reconciliation: 34 run, 0 NOT-MEASURED, 0 UNRUN. Tier: no path-derived mandate; 224 changed lines. <details><summary>Derived gate exits</summary> Every family below exited 0: `check-adr-0087-registration` (and `--self-test`), `check-changeset-no-major`, `check-closing-keyword-parity` (and `--self-test`), `check-comment-mask-corpus`, `check-empty-changeset` (and `--self-test`), `check-sdui-manifest` (and `--self-test`), `release-rehearsal-clone --self-test`, `release-pending-publish --self-test`, `pnpm --dir ui exec vitest run scripts/` (174 files passed, 2 skipped; 4987 tests passed), `spec check:duration-unit-keys`, and `check:` changeset-gate-self-tests, closed-ceremony-text, cross-package-test-inputs, dispatcher-error-vocabulary, driver-memory-census, engine-double-contract, gitlink-declared, nul-bytes, pm-changeset-deadline-census, published-files, query-options-erasure, refd-timer-probe, skill-identifier-liveness, test-source-alias, tier-file-adoption, type-check-coverage, type-check-debt, undeclared-dep-imports, watch-hint-literal, where-matcher. </details> Repo-wide lint, the full ui test battery and the e2e jobs are CI's. --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWuU6sVfBYiy4dY4PA)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 01516dc commit 7e200b4

3 files changed

Lines changed: 221 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
---
5+
6+
fix(studio): a read-only package's flow Enabled switch no longer looks like a live control
7+
8+
Clause-②: no
9+
Metadata-protocol: none
10+
11+
- **Before.** In Studio's Automations pillar, a read-only (code) package's flow showed its Enabled switch with a green track. The switch was already disabled, but it read as one the author could flip.
12+
- **Now.** On a read-only package the track is muted and a lock sits beside the word. The thumb position, the word (Enabled or Disabled) and `aria-checked` still say whether the flow is on. The read-only reason on the wrapper is unchanged.
13+
- **Unchanged.** A writable package's switch keeps its green track and stays operable.
Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#27356 (hotcrm#2320 Studio S10) — a read-only package's flow
5+
* "Enabled" switch is not drawn like a live control. It was already disabled
6+
* (objectui#11124), yet its track stayed green, so it read as a switch the
7+
* author could flip. On a read-only package the track is muted and a lock sits
8+
* beside the word; the thumb, the word and `aria-checked` still say on or off.
9+
* A writable package's switch keeps the green track and no lock (the control).
10+
*
11+
* The canvas is the REAL registered `FlowPreview`; the client is a server
12+
* double, as in the objectui#11124 file this harness is copied from.
13+
*/
14+
15+
import '@testing-library/jest-dom/vitest';
16+
import * as React from 'react';
17+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18+
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
19+
import { MemoryRouter } from 'react-router-dom';
20+
21+
const PKG = 'com.acme.app';
22+
23+
const NODES = [
24+
{ id: 'start', type: 'start', label: 'Start' },
25+
{ id: 'end', type: 'end', label: 'End' },
26+
];
27+
const EDGES = [{ id: 'e1', source: 'start', target: 'end' }];
28+
const FLOW = { name: 'notify_owner', label: 'Notify owner', type: 'autolaunched', status: 'active', nodes: NODES, edges: EDGES };
29+
30+
const server = vi.hoisted(() => ({
31+
active: new Map<string, Record<string, unknown>>(),
32+
saves: [] as Array<{ type: string; name: string; body: Record<string, unknown> }>,
33+
}));
34+
35+
const mockClient = vi.hoisted(() => {
36+
const k = (type: string, name: string) => `${type}/${name}`;
37+
return {
38+
list: vi.fn(async (type: string) =>
39+
[...server.active.entries()]
40+
.filter(([key]) => key.startsWith(`${type}/`))
41+
.map(([, row]) => ({ name: row.name, label: row.label ?? row.name })),
42+
),
43+
listDrafts: vi.fn(async () => []),
44+
listTypes: vi.fn(async () => ({ entries: [] })),
45+
get: vi.fn(async () => null),
46+
references: vi.fn(async () => []),
47+
layered: vi.fn(async (type: string, name: string) => {
48+
const eff = server.active.get(k(type, name)) ?? null;
49+
return { code: null, overlay: eff, overlayScope: eff ? 'env' : null, effective: eff, editable: true, deletable: true, resettable: false, lock: 'none' };
50+
}),
51+
getDraft: vi.fn(async (type: string, name: string) => {
52+
throw Object.assign(new Error(`No pending draft exists for ${type}/${name}.`), { code: 'NO_DRAFT', status: 404 });
53+
}),
54+
save: vi.fn(async (type: string, name: string, item: unknown) => {
55+
server.saves.push({ type, name, body: JSON.parse(JSON.stringify(item)) as Record<string, unknown> });
56+
return { type, name, item };
57+
}),
58+
publish: vi.fn(async () => ({ success: true })),
59+
reset: vi.fn(async () => ({})),
60+
};
61+
});
62+
63+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
64+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
65+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
66+
});
67+
68+
vi.mock('./packages-io', async (importOriginal) => {
69+
const mod = await importOriginal<typeof import('./packages-io')>();
70+
return { ...mod, fetchPackages: vi.fn(async () => []) };
71+
});
72+
73+
vi.mock('@object-ui/react', async (importOriginal) => {
74+
const mod = await importOriginal<typeof import('@object-ui/react')>();
75+
return { ...mod, useAdapter: () => dataSource };
76+
});
77+
78+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
79+
80+
import { AutomationsPillar } from './StudioDesignSurface';
81+
import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource';
82+
import { registerMetadataPreview } from '../metadata-admin/preview-registry';
83+
import { FlowPreview } from '../metadata-admin/previews/FlowPreview';
84+
import { t } from '../metadata-admin/i18n';
85+
86+
const dataSource = createEmptyDataSource();
87+
failOnAbsorbedFetchError();
88+
89+
// The pillar's `/automation/_status` probe and the inspector's action-catalog
90+
// read both go through the global `fetch`; one module-scope double answers
91+
// "absent" so each keeps its documented fallback.
92+
vi.stubGlobal(
93+
'fetch',
94+
vi.fn(async () => new Response('null', { status: 404, headers: { 'content-type': 'application/json' } })),
95+
);
96+
97+
registerMetadataPreview('flow', FlowPreview);
98+
99+
const key = (type: string, name: string) => `${type}/${name}`;
100+
101+
beforeEach(() => {
102+
server.active.clear();
103+
server.saves.length = 0;
104+
for (const fn of Object.values(mockClient)) (fn as unknown as { mockClear: () => void }).mockClear();
105+
});
106+
107+
afterEach(cleanup);
108+
109+
function renderPillar(readOnly: boolean, status: 'active' | 'obsolete') {
110+
server.active.set(key('flow', FLOW.name), { ...JSON.parse(JSON.stringify(FLOW)), status });
111+
return render(
112+
<MemoryRouter initialEntries={[`/studio/${PKG}/automations`]}>
113+
<AutomationsPillar packageId={PKG} readOnly={readOnly} />
114+
</MemoryRouter>,
115+
);
116+
}
117+
118+
const STATUS_ON = t('engine.studio.auto.enabled', 'en');
119+
const STATUS_OFF = t('engine.studio.auto.disabled', 'en');
120+
121+
/** The switch, once the open flow's document is in (it is not offered before). */
122+
async function openSwitch(word: string): Promise<HTMLElement> {
123+
return waitFor(
124+
() => {
125+
const toggle = screen.getByRole('switch');
126+
expect(toggle).toHaveTextContent(word);
127+
return toggle;
128+
},
129+
{ timeout: 8000 },
130+
);
131+
}
132+
133+
/** The switch's track: its first child, the pill the thumb slides in. */
134+
const trackOf = (toggle: HTMLElement) => toggle.firstElementChild as HTMLElement;
135+
const thumbOf = (toggle: HTMLElement) => trackOf(toggle).firstElementChild as HTMLElement;
136+
const lockOf = (toggle: HTMLElement) => toggle.querySelector('svg.lucide-lock');
137+
138+
describe('A read-only package draws the flow switch muted, with a lock (objectstack#27356)', () => {
139+
it('an enabled flow: no green track, a lock, and still on', async () => {
140+
renderPillar(true, 'active');
141+
const toggle = await openSwitch(STATUS_ON);
142+
143+
expect(toggle).toBeDisabled();
144+
expect(toggle).toHaveAttribute('aria-checked', 'true');
145+
expect(trackOf(toggle).className).not.toContain('bg-emerald-500');
146+
expect(trackOf(toggle).className).toContain('bg-muted-foreground/60');
147+
expect(thumbOf(toggle).className).toContain('translate-x-[18px]');
148+
expect(lockOf(toggle)).not.toBeNull();
149+
expect(lockOf(toggle)).toHaveAttribute('aria-hidden', 'true');
150+
// Not a live control: no hover fill.
151+
expect(toggle.className).not.toContain('hover:bg-muted');
152+
153+
fireEvent.click(toggle);
154+
await new Promise((r) => setTimeout(r, 50));
155+
expect(server.saves).toHaveLength(0);
156+
});
157+
158+
it('a disabled flow: the same lock, a fainter track and the thumb at off', async () => {
159+
renderPillar(true, 'obsolete');
160+
const toggle = await openSwitch(STATUS_OFF);
161+
162+
expect(toggle).toHaveAttribute('aria-checked', 'false');
163+
expect(trackOf(toggle).className).toContain('bg-muted-foreground/25');
164+
expect(thumbOf(toggle).className).toContain('translate-x-0.5');
165+
expect(lockOf(toggle)).not.toBeNull();
166+
});
167+
});
168+
169+
describe('A writable package keeps the live switch — the control (objectstack#27356)', () => {
170+
it('an enabled flow: green track, no lock, operable', async () => {
171+
renderPillar(false, 'active');
172+
const toggle = await openSwitch(STATUS_ON);
173+
174+
expect(toggle).toBeEnabled();
175+
expect(trackOf(toggle).className).toContain('bg-emerald-500');
176+
expect(lockOf(toggle)).toBeNull();
177+
expect(toggle.className).toContain('hover:bg-muted');
178+
179+
fireEvent.click(toggle);
180+
await waitFor(() => expect(server.saves).toHaveLength(1), { timeout: 8000 });
181+
expect(server.saves[0]!.body.status).toBe('obsolete');
182+
});
183+
184+
it('a disabled flow: the grey track it always had, no lock', async () => {
185+
renderPillar(false, 'obsolete');
186+
const toggle = await openSwitch(STATUS_OFF);
187+
188+
expect(trackOf(toggle).className).toContain('bg-muted-foreground/40');
189+
expect(lockOf(toggle)).toBeNull();
190+
});
191+
});

‎ui/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8778,6 +8778,17 @@ export function AutomationsPillar({
87788778
current && flowLoaded
87798779
? (() => {
87808780
const defaultOff = t('engine.studio.auto.defaultOff', locale);
8781+
// objectstack#27356 — a read-only package's switch cannot be flipped,
8782+
// so it is not drawn like a live control: no green track and no
8783+
// hover, and a lock beside the word. The thumb and the word still
8784+
// say on or off, as the flow's status does.
8785+
const track = readOnly
8786+
? flowEnabled
8787+
? 'bg-muted-foreground/60'
8788+
: 'bg-muted-foreground/25'
8789+
: flowEnabled
8790+
? 'bg-emerald-500'
8791+
: 'bg-muted-foreground/40';
87818792
const toggle = (
87828793
<button
87838794
type="button"
@@ -8794,13 +8805,16 @@ export function AutomationsPillar({
87948805
: `${defaultOff}\n${flowEnabled ? t('engine.studio.auto.disableTitle', locale) : t('engine.studio.auto.enableTitle', locale)}`
87958806
}
87968807
className={
8797-
'inline-flex h-7 items-center gap-2 rounded-md px-1 text-[12px] text-foreground hover:bg-muted disabled:opacity-50' +
8798-
(readOnly ? ' disabled:pointer-events-none' : '')
8808+
'inline-flex h-7 items-center gap-2 rounded-md px-1 text-[12px] ' +
8809+
(readOnly
8810+
? 'text-muted-foreground disabled:pointer-events-none'
8811+
: 'text-foreground hover:bg-muted disabled:opacity-50')
87998812
}
88008813
>
8801-
<span className={'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ' + (flowEnabled ? 'bg-emerald-500' : 'bg-muted-foreground/40')}>
8814+
<span className={'relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ' + track}>
88028815
<span className={'inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ' + (flowEnabled ? 'translate-x-[18px]' : 'translate-x-0.5')} />
88038816
</span>
8817+
{readOnly && <Lock className="h-3 w-3 shrink-0" aria-hidden="true" />}
88048818
{flowEnabled ? t('engine.studio.auto.enabled', locale) : t('engine.studio.auto.disabled', locale)}
88058819
</button>
88068820
);

0 commit comments

Comments
 (0)