Skip to content

Commit 97c2982

Browse files
feat(app-shell): a Studio item header keeps the item name readable; chips, tools and labels give way first (#29765)
domain:ui#4 · zhuangjianguo · session_01QsR2jR7ZFkpqmm4U8S1f9y <!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbL3tNMC4Khinp6atoAn1ou3)_ Fixes #29693 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 条目标题行在窄栏(约 1024 px 窗口、右侧面板打开)不再把条目名挤没。标题至少保留前 12 个字符(名称更短则整名);先让芯片和五个条目工具收进 ⋯ 菜单,再让「自定义」、流程开关、设计/运行按钮只留图标;轨道上的定制标记改为单个图标。 - **为什么改**:实测 main 上 Interfaces 报表、对象切片和流程三个条目标题宽度为 0 px,⋯ 按钮落在右侧面板下面点不到;流程轨道选中的定制行只剩「已定制(ObjectStack Showcase)」。 - **风险与回滚**:只动 app-shell 前端展示,无元数据与接口变化;没传 `foldMenu` 的标题行行为不变。回滚即 revert 本 PR。 - **先看**:`StudioItemHeader.tsx`(折叠测量 `useTitleRowFold` 与菜单内容 `StudioItemHeaderFoldedItems`)、`StudioItemTools.tsx`(工具交出菜单形态)、`StudioDesignSurface.tsx`(三个标题行的 `foldMenu`)。 - **两分钟验证**:浏览器窗口 1024 px,打开 Studio 界面支柱里一个已定制且有草稿的报表(右侧属性面板打开):标题显示完整名称,⋯ 可点开,菜单顶部依次是芯片、层次/引用关系/历史/审计日志/源码。 - **需要你做的**:无,席位落地。 ## What changes The item header's title row (`StudioItemHeader`) now keeps the item's name readable, measured on the header's own row, never the viewport: - **The readable width** is the width of the title's first twelve visible characters (a text range over the title, the reader-only kind suffix excluded), capped at the whole name. - **Stage 1.** When the room the row leaves the title is below that width, the identity chips (api-name chip and the pillar's `chips`: kind, override, pair) and the five item tools leave the row. They read at the top of the ⋯ menu: the chips as one menu entry whose choice keeps the menu open, then the tools as menu items with the same test ids as the buttons, opening the same sheets. `StudioItemTools` hands its menu form to the header through `useStudioItemHeaderFold`. - **Stage 2.** If the title is still short, labelled actions show their icon alone: the Customize button, the flow ledger switch and definition switch, and the Interfaces Design/Run buttons. The label stays in the DOM as the accessible name (`STUDIO_FOLD_LABEL`, a `group-data` variant of `sr-only` keyed on the actions slot's `data-fold` attribute). Customize keeps the first slot of the actions. - **Last.** Only then is the title cut, with its whole text in its tooltip as before (objectui#12897). The ⋯ trigger stays inside the row in every stage. - **Giving back.** Each stage records the room it freed and is undone only once the title would keep its readable width without that room, so a stage never undoes itself the moment it is taken. - **Where folded things go** is the pillar's call (`foldMenu`): `'pillar'` for Interfaces and Automations, whose own ⋯ menus mount `StudioItemHeaderFoldedItems`; an object for the Data header (and for an Interfaces or flow item while its own menu is not drawn), where the header draws a ⋯ of its own while something is folded. A header without `foldMenu` (Access, the permission matrix) is unchanged. - **Rail mark.** `RailCustomizationMark` draws `CustomizationMark` with `compact`: the glyph alone (violet pencil-ruler for customized, emerald plus for added), the text as its accessible name and the first line of its tooltip. The All metadata lists keep the text mark. Files beyond the claim's first surface (`StudioCustomizeControls.tsx`, `FlowLedgerSwitch.tsx`, `StudioItemTools.tsx`, `FlowItemMoreMenu.tsx`) are covered by the seat's amendment record 6593712299. ### For the cards that build on this header - **#29691 (the "saved to" sentence):** it goes on its own line directly under the title row and above the tab row, as a new optional slot of `StudioItemHeader` rendered between the title-row div and the `nav` tab row. Keep it out of the title row: the fold measures the title row's boxes only, so a line under it costs the title nothing. - **#29692 (Restore on the override chip, as a popover):** when the row folds, the override chip is inside the single menu entry `studio-item-header-folded-chips`, which keeps the menu open when chosen, so a popover trigger on the chip still works from there. Restore package original also stays a ⋯ menu item in both the Interfaces and flow menus. ## Tests - `StudioItemHeader.titleRowFold-12897.test.tsx` (shared stubbed-geometry harness), two new describes: stage 1 at 330 px, the menu's contents and a tool firing its action, following the row both ways, a 470 px CONTROL that stays folded, a 632 px CONTROL with nothing folded, and a CONTROL header without `foldMenu`; then the order: stage 1 alone at 560 px, stage 2 at 420 px, the title cut last at 300 px with the ⋯ still there, unfolding in reverse, icon-only Customize and ledger switch keeping their labels as accessible names and firing the same handlers, and the chips as one menu entry. - `StudioDesignSurface.itemTools-28239.test.tsx`: the real `StudioItemTools` folded into the header's ⋯; each of Layers, References, History, Audit and Source opens its sheet from the menu and reads the same door as its button. - `StudioDesignSurface.oneSurface-28572.test.tsx`: the Interfaces rail's customized and added marks are glyph-only with their text as accessible name and tooltip lead. <details><summary>Readings</summary> - New and touched files: `titleRowFold-12897` 17/17, `itemTools-28239` 9/9, `oneSurface-28572` 13/13. - Ablation (fold hook made to return stage 0, through `scripts/ablation-replace.mjs`, restored to the HEAD blob): `titleRowFold-12897` 10 failed, 7 passed; the 7 are the five objectui#12897 cases and the two no-fold CONTROLs. - Every app-shell test file naming a touched module (`git grep -l` over StudioItemHeader, StudioItemTools, CustomizationMark, StudioRailCustomizations, FlowItemMoreMenu, FlowLedgerSwitch, StudioCustomizeControls, studio-tokens, StudioDesignSurface): 228 files, 1713 tests, all passed, in five locked batches at head 08039a0. The later commits are the changeset text, a test prop the type-check asked for, and the merge of `main`. - After merging `main` (head eaea6c6, 23 incoming commits, one touching `StudioDesignSurface.tsx`'s Access banner, no overlap): app-shell `type-check` (src and tests) exit 0; `check-sdui-manifest` exit 0; the three pin files plus the two incoming Studio tests that touch `StudioDesignSurface` (`accessPairCopy-29674`, `customizePublish-28562`): 5 files, 49 tests passed. Merged `main` again right before opening (head 205c453, six incoming commits; one changes `StudioDesignSurface.tsx`'s field-name helpers only): app-shell `type-check` exit 0, `check-sdui-manifest` exit 0, the three pin files 39/39. - `pnpm --dir ui exec vitest run scripts/`: 170 files passed, 2 skipped. - Derived gates (`dispatch-gates --commands`): 35 derived, 35 run, all exit 0; `--ran` reconciles 0 NOT-MEASURED. - eslint on the touched files: 0 errors. - Live E2E (`studio-customize-journey.spec.ts`, seat amendment record 6595035315): at the job's 1280 x 720 window with the Interfaces inspector open, the header folds its identity chips, so the published pair's override chip is looked for in the row, else in `studio-item-header-folded-chips` after opening `if-item-more`, and the menu is closed again. Run locally against the showcase backend (`ui/e2e/live/ci/start-backend.sh`, Console built with `VITE_BASE_PATH=/` and served by `vite preview`): the spec at 205c453 fails at L137 exactly as CI job 125715096807 did (`studio-override-chip` not found); with the fix (b0949a5) 3 of 3 runs pass (19.3 s, 17.3 s, 17.8 s). Derived gates on b0949a5: 35 derived, 35 run, all exit 0; `--ran` 0 NOT-MEASURED; eslint on the spec 0. </details> ## Acceptance notes - At 1024 px with the right panel open the Data object header did not need to fold: "Account (Success Overlay)" shows 13 characters beside its tools, above the 12-character floor, so the tools stay in the row. - The Access set header has no `foldMenu`: it has no chips or tools to fold, and its name was whole before and after. ## Verification Live, on the showcase app (`objectstack dev --seed-admin --fresh`) with the Console on Vite, Chromium at 1024 x 768, zh-CN, right panel open. The report and the flow were customized, published, and edited again, so each header carries its kind chip, override chip, draft status, the five tools and ⋯. Before = this branch with the nine changed source files swapped back to `origin/main`; after = this branch. | Header | Row | Title before | Title after | ⋯ before | ⋯ after | |---|---|---|---|---|---| | Interfaces report 工时按状态分布 | 436 px | 0 of 125 px | 125 px, whole | x 875 to 907, under the panel, not hittable | x 668 to 700, hittable | | Interfaces slice 进行中的任务 | 436 px | 0 of 107 px | 107 px, whole | x 790 to 822, not hittable | x 668 to 700, hittable | | Automations Project Budget Approval | 436 px | 0 of 207 px | 145 px ("Project Budget…", 14 characters) | x 863 to 895, not hittable | x 668 to 700, hittable | | Data Account (Success Overlay), field panel open | 436 px | 153 of 233 px | 153 px, unchanged | none | none | | Access 示例贡献者 | 736 px | 89 px, whole | 89 px, whole | hittable | hittable | Before, the three folding rows were wider than the row (scroll widths 643, 558 and 631 px for a 436 px row). After, the ⋯ menus list the folded chips entry, then Layers, References, History, Audit, Source, then the pillar's own items. The flow rail's selected customized row reads "Project Budget …" with the glyph mark. Screenshots and the raw readings are with the seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUYXmZ6DQBWc6piyyF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f87239a commit 97c2982

14 files changed

Lines changed: 842 additions & 46 deletions
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
feat(app-shell): a Studio item header keeps the item's name readable; its chips, item tools and action labels give way first
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
On a narrow content column (a laptop window with the right panel open) the item header's title row no longer cuts the item's name to nothing. The title keeps its first twelve characters (its whole name when shorter), and the row gives way in two steps before the title does. First the identity chips (the api-name chip, the kind chip, the override and pair chips) and the five item tools (Layers, References, History, Audit, Source) leave the row and read at the top of the item's ⋯ menu: the chips as one entry, the tools as menu items opening the same sheets. Then the labelled actions (Customize, a flow's on/off switches, the Design and Run buttons) show their icon alone, the label staying their accessible name. The Interfaces and Automations headers fold into their own ⋯ menu; the Data header, which has none, draws one while something is folded. With the row's boxes fitting the row again, the ⋯ trigger stays inside the column instead of under the right panel. The draft status chip stays in the row. `StudioItemHeader` takes this as a `foldMenu` prop and exports `StudioItemHeaderFoldedItems` for a pillar's own menu; a header without `foldMenu` is unchanged.
11+
12+
The pillar rails draw the customization mark as its glyph alone ("Customized (BASE)" and "Added" are its accessible name and lead its tooltip), so a customized row keeps its label. The All metadata lists keep the mark's text.

‎ui/e2e/live/studio-customize-journey.spec.ts‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,29 @@ async function publishPair(page: Page) {
9595
expect((await res.json()).data.outcome).toBe('published');
9696
}
9797

98+
/**
99+
* The override chip is reachable on the item header once the pair is published.
100+
* When the row has room it is in the row. On a narrower content column (the
101+
* job's 1280 px window with the inspector open) the header folds its identity
102+
* chips into the item's ⋯ menu first, so the item's name stays readable
103+
* (objectstack#29693), and there the chip sits in the menu's chips entry. The
104+
* menu is closed again so the next step opens it fresh.
105+
*/
106+
async function expectOverrideChip(page: Page) {
107+
const chip = page.getByTestId('studio-override-chip');
108+
const entry = page.getByTestId('studio-item-header-folded-chips');
109+
await expect(async () => {
110+
if (await chip.isVisible()) return;
111+
await page.getByTestId('if-item-more').click();
112+
try {
113+
await expect(entry.getByTestId('studio-override-chip')).toBeVisible({ timeout: 1_000 });
114+
} finally {
115+
await page.keyboard.press('Escape');
116+
}
117+
}).toPass({ timeout: 10_000 });
118+
await expect(entry).toHaveCount(0);
119+
}
120+
98121
test('a read-only base is customized, restored and extended through Studio, and its uninstall is refused', async ({ page, request }) => {
99122
const baseRes = await request.get(`${API}/api/v1/packages/${BASE}`);
100123
expect(baseRes.ok(), await baseRes.text()).toBeTruthy();
@@ -134,7 +157,7 @@ test('a read-only base is customized, restored and extended through Studio, and
134157

135158
await publishPair(page);
136159
expect(await threeReads(request)).toEqual({ bare: customized, base: customized, list: customized });
137-
await expect(page.getByTestId('studio-override-chip')).toBeVisible();
160+
await expectOverrideChip(page);
138161
});
139162

140163
await test.step('Restore package original: the pair\'s draft and row go, and the shipped columns are served again', async () => {

‎ui/packages/app-shell/src/views/item-tools/StudioItemTools.tsx‎

Lines changed: 54 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
2222
* (`source.onChange`) and the item is not read-only, so an edit there is
2323
* saved by the pillar's autosave like any other. Without a buffer it shows
2424
* the item's effective body, read-only.
25+
*
26+
* objectstack#29693 — when the item header folds (the title would drop below
27+
* its readable width), the five buttons leave the row and the same tools read
28+
* as items of the header's ⋯ menu, opening the same sheets.
2529
*/
2630

2731
import * as React from 'react';
@@ -44,6 +48,7 @@ import { ReferencesPanel, useReferencesScan } from './ReferencesPanel.js';
4448
import { HistoryPanel } from './ResourceHistoryPage.js';
4549
import { AuditPanel } from './AuditPanel.js';
4650
import { JsonSourceEditor } from './JsonSourceEditor.js';
51+
import { useStudioItemHeaderFold, type StudioFoldedTool } from '../studio-design/StudioItemHeader.js';
4752

4853
/** The tool a sheet holds. */
4954
export type StudioItemTool = 'layers' | 'references' | 'history' | 'audit' | 'source';
@@ -123,6 +128,31 @@ function StudioItemToolsFor({
123128
// eslint-disable-next-line react-hooks/exhaustive-deps
124129
}, [open]);
125130

131+
// objectstack#29693 — the tools' menu form, handed to the header's fold.
132+
const fold = useStudioItemHeaderFold();
133+
const setFoldedTools = fold?.setTools;
134+
const refsCount = refs.state.status === 'loaded' ? refs.state.items.length : undefined;
135+
React.useEffect(() => {
136+
if (!setFoldedTools) return;
137+
const tool = (id: StudioItemTool, label: string, icon: React.ElementType, count?: number): StudioFoldedTool => ({
138+
id,
139+
label,
140+
icon,
141+
count,
142+
onSelect: () => setOpen(id),
143+
});
144+
setFoldedTools([
145+
tool('layers', t('engine.edit.layers', locale), Layers3),
146+
tool('references', t('engine.edit.references', locale), Link2, refsCount),
147+
...(canReadTrail
148+
? [tool('history', t('engine.edit.history', locale), History), tool('audit', t('engine.edit.auditTab', locale), ShieldCheck)]
149+
: []),
150+
tool('source', t('engine.edit.inspector.source', locale), FileCode2),
151+
]);
152+
return () => setFoldedTools(null);
153+
}, [setFoldedTools, canReadTrail, refsCount, locale]);
154+
const folded = fold?.folded ?? false;
155+
126156
const canRollback = !readOnly && canAuthor;
127157
const sourceEditable = !readOnly && !!source?.onChange;
128158
const close = (o: boolean) => {
@@ -181,27 +211,30 @@ function StudioItemToolsFor({
181211

182212
return (
183213
<>
184-
<div
185-
role="group"
186-
aria-label={t('engine.studio.itemTools.group', locale)}
187-
data-testid="studio-item-tools"
188-
className="flex shrink-0 items-center rounded-md border bg-background p-0.5"
189-
>
190-
{toolButton('layers', t('engine.edit.layers', locale), Layers3)}
191-
{toolButton(
192-
'references',
193-
t('engine.edit.references', locale),
194-
Link2,
195-
refs.state.status === 'loaded' && refs.state.items.length > 0 ? (
196-
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-muted text-foreground text-[9px] leading-[14px] text-center font-medium border">
197-
{refs.state.items.length}
198-
</span>
199-
) : undefined,
200-
)}
201-
{canReadTrail && toolButton('history', t('engine.edit.history', locale), History)}
202-
{canReadTrail && toolButton('audit', t('engine.edit.auditTab', locale), ShieldCheck)}
203-
{toolButton('source', t('engine.edit.inspector.source', locale), FileCode2)}
204-
</div>
214+
{!folded && (
215+
<div
216+
role="group"
217+
aria-label={t('engine.studio.itemTools.group', locale)}
218+
data-testid="studio-item-tools"
219+
data-studio-fold="tools"
220+
className="flex shrink-0 items-center rounded-md border bg-background p-0.5"
221+
>
222+
{toolButton('layers', t('engine.edit.layers', locale), Layers3)}
223+
{toolButton(
224+
'references',
225+
t('engine.edit.references', locale),
226+
Link2,
227+
refs.state.status === 'loaded' && refs.state.items.length > 0 ? (
228+
<span className="absolute -top-1 -right-1 min-w-[14px] h-[14px] px-1 rounded-full bg-muted text-foreground text-[9px] leading-[14px] text-center font-medium border">
229+
{refs.state.items.length}
230+
</span>
231+
) : undefined,
232+
)}
233+
{canReadTrail && toolButton('history', t('engine.edit.history', locale), History)}
234+
{canReadTrail && toolButton('audit', t('engine.edit.auditTab', locale), ShieldCheck)}
235+
{toolButton('source', t('engine.edit.inspector.source', locale), FileCode2)}
236+
</div>
237+
)}
205238

206239
{sheet('layers', t('engine.edit.layers', locale), <div className="flex-1 min-h-0 overflow-auto p-4">{layersBody}</div>)}
207240

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616

1717
import * as React from 'react';
18+
import { Layers3, PencilRuler, Plus } from 'lucide-react';
1819
import { cn } from '@object-ui/components';
1920
import { customizationPackageId } from '@objectstack/spec/kernel';
2021
import { t, tFormat } from '../metadata-admin/i18n.js';
@@ -35,12 +36,26 @@ const KIND_CLASS: Record<CustomizationMarkChipKind, string> = {
3536
customizationOf: 'bg-muted text-muted-foreground',
3637
};
3738

39+
/** objectstack#29693 — the glyph the compact mark draws in place of its text. */
40+
const KIND_ICON: Record<CustomizationMarkChipKind, React.ElementType> = {
41+
customized: PencilRuler,
42+
added: Plus,
43+
customizationOf: Layers3,
44+
};
45+
46+
/**
47+
* objectstack#29693 — `compact` (the pillar rails): the mark is its glyph
48+
* alone, so it never takes a row's name ("已定制(ObjectStack Showcase)" left a
49+
* selected flow row no room for its label). Its text is its accessible name
50+
* and leads its tooltip, before the sentence naming the pair.
51+
*/
3852
export function CustomizationMark({
3953
kind,
4054
baseName,
4155
pairId,
4256
locale,
4357
className,
58+
compact = false,
4459
}: {
4560
kind: CustomizationMarkChipKind;
4661
/** The base's display name ("customized (HotCRM)"); needed by `customized` and `customizationOf`. */
@@ -49,6 +64,8 @@ export function CustomizationMark({
4964
pairId?: string;
5065
locale: string;
5166
className?: string;
67+
/** Draw the glyph alone, the text in its accessible name and tooltip. */
68+
compact?: boolean;
5269
}): React.ReactElement {
5370
const base = baseName ?? '';
5471
const label =
@@ -58,6 +75,22 @@ export function CustomizationMark({
5875
const title = pairId
5976
? tFormat(`engine.customizationMark.${kind}Title`, locale, { baseName: base, pair: pairId })
6077
: undefined;
78+
if (compact) {
79+
const Icon = KIND_ICON[kind];
80+
return (
81+
<span
82+
data-testid="customization-mark"
83+
data-kind={kind}
84+
data-compact=""
85+
role="img"
86+
aria-label={label}
87+
title={title ? `${label}\n${title}` : label}
88+
className={cn('inline-flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded-[5px]', KIND_CLASS[kind], className)}
89+
>
90+
<Icon className="h-3 w-3" aria-hidden="true" />
91+
</span>
92+
);
93+
}
6194
return (
6295
<span
6396
data-testid="customization-mark"

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import {
5050
Label,
5151
} from '@object-ui/components';
5252
import { actionErrorDetail } from '@object-ui/core';
53+
import { StudioItemHeaderFoldedItems } from './StudioItemHeader.js';
5354
import { Copy, Loader2, MoreHorizontal, Trash2 } from 'lucide-react';
5455
import { t, tFormat, translateMetadataType } from '../metadata-admin/i18n.js';
5556
import { envelopeData, envelopeRefused } from '../setup/packagedFlows.js';
@@ -193,6 +194,8 @@ export function FlowItemMoreMenu({ name, label, locale, disabled, onDelete, newS
193194
</button>
194195
</DropdownMenuTrigger>
195196
<DropdownMenuContent align="end" className="w-52">
197+
{/* objectstack#29693 — what the flow header's title row folded away, on a narrow row. */}
198+
<StudioItemHeaderFoldedItems />
196199
{newSimilar && (
197200
<DropdownMenuItem
198201
data-testid="auto-item-new-similar"

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

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import * as React from 'react';
3333
import { Power } from 'lucide-react';
3434
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@object-ui/components';
3535
import { t } from '../metadata-admin/i18n.js';
36+
import { STUDIO_FOLD_LABEL } from './studio-tokens.js';
3637
import type { ActivationReading } from '../setup/packagedFlows.js';
3738

3839
export function FlowLedgerSwitch({ checked, reading, notRunningReason, busy, walledReason, locale, onToggle }: {
@@ -99,11 +100,14 @@ export function FlowLedgerSwitch({ checked, reading, notRunningReason, busy, wal
99100
/>
100101
</span>
101102
<Power className="h-3 w-3 shrink-0" aria-hidden="true" />
102-
{reading === 'on'
103-
? t('engine.studio.auto.ledger.on', locale)
104-
: notRunning
105-
? t('engine.studio.auto.ledger.notRunning', locale)
106-
: t('engine.studio.auto.ledger.off', locale)}
103+
{/* objectstack#29693 — on a narrow item header the switch keeps its track and glyph; the word stays its name. */}
104+
<span data-studio-fold-label="" className={STUDIO_FOLD_LABEL}>
105+
{reading === 'on'
106+
? t('engine.studio.auto.ledger.on', locale)
107+
: notRunning
108+
? t('engine.studio.auto.ledger.notRunning', locale)
109+
: t('engine.studio.auto.ledger.off', locale)}
110+
</span>
107111
</button>
108112
);
109113
const hintLine = (

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
import { Layers, Pencil, RotateCcw } from 'lucide-react';
5959
import { postureHasOrgWall, useTenancyPosture } from '../../hooks/useTenancyPosture.js';
6060
import { t, tFormat } from '../metadata-admin/i18n.js';
61+
import { STUDIO_FOLD_LABEL } from './studio-tokens.js';
6162
import { readFields, writeFields, type FieldEntry } from '../metadata-admin/previews/object-fields-io.js';
6263

6364
/**
@@ -241,7 +242,10 @@ export function StudioCustomizeButton({
241242
)}
242243
>
243244
<Pencil className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-hidden="true" />
244-
{t('engine.studio.customize.action', locale)}
245+
{/* objectstack#29693 — on a narrow item header the button keeps its glyph; the word stays its name. */}
246+
<span data-studio-fold-label="" className={STUDIO_FOLD_LABEL}>
247+
{t('engine.studio.customize.action', locale)}
248+
</span>
245249
</button>
246250
);
247251
if (reason === null) return button;

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ vi.mock('@monaco-editor/react', () => {
126126
vi.mock('@object-ui/plugin-editor', () => ({ configureBundledMonaco: () => Promise.resolve() }));
127127

128128
import { AutomationsPillar, DataPillar, InterfacesPillar } from './StudioDesignSurface';
129+
import { StudioItemHeader } from './StudioItemHeader';
130+
import { StudioItemTools } from '../item-tools/StudioItemTools';
129131
import { MetadataResourceEditPage } from '../metadata-admin/ResourceEditPage';
130132
import '../../services/builtinComponents.js';
131133
import { t } from '../metadata-admin/i18n';
@@ -305,3 +307,78 @@ describe("CONTROL — Setup's item page still opens the five from the shared dir
305307
expect(await screen.findByRole('textbox', { name: 'JSON source' }, { timeout: 8000 })).toBeInTheDocument();
306308
});
307309
});
310+
311+
/**
312+
* objectstack#29693 — on a row too narrow for the title's twelve characters,
313+
* the five tools fold into the header's ⋯ menu. Each menu entry opens the same
314+
* sheet its button opens, reading the same door. The row's geometry is stubbed
315+
* (no layout here): a 100 px row, the button group 146 px wide, the title's
316+
* whole text 200 px and its first twelve characters 120 px.
317+
*/
318+
describe('Studio item toolbar — folded into the header’s ⋯ menu, each tool opens the same sheet (objectstack#29693)', () => {
319+
const spies: Array<{ mockRestore: () => void }> = [];
320+
const box = (width: number) => ({ left: 0, right: width, width, top: 0, bottom: 32, height: 32, x: 0, y: 0, toJSON: () => ({}) }) as DOMRect;
321+
beforeEach(() => {
322+
const realRect = HTMLElement.prototype.getBoundingClientRect;
323+
spies.push(
324+
vi.spyOn(Range.prototype, 'getBoundingClientRect').mockImplementation(() => box(120)),
325+
vi.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(function (this: HTMLElement) {
326+
return this.classList.contains('@container') ? 100 : 0;
327+
}),
328+
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
329+
return this.dataset.testid === 'studio-item-tools' ? box(146) : realRect.call(this);
330+
}),
331+
// The title's whole text, wider than its twelve characters.
332+
vi.spyOn(HTMLElement.prototype, 'scrollWidth', 'get').mockImplementation(function (this: HTMLElement) {
333+
return this.tagName === 'H1' ? 200 : 0;
334+
}),
335+
);
336+
});
337+
afterEach(() => {
338+
for (const spy of spies.splice(0)) spy.mockRestore();
339+
});
340+
341+
it('Layers, References, History, Audit and Source open from the menu on object/showcase_account', async () => {
342+
render(
343+
<MemoryRouter>
344+
<StudioItemHeader
345+
title="Account"
346+
foldMenu={{ label: 'More', testId: 'hdr-more' }}
347+
actions={<StudioItemTools type="object" name="showcase_account" packageId={PKG} locale="en" />}
348+
/>
349+
</MemoryRouter>,
350+
);
351+
await waitFor(() => expect(screen.queryByTestId('studio-item-tools')).toBeNull());
352+
const pick = async (tool: string) => {
353+
fireEvent.pointerDown(screen.getByTestId('hdr-more'), { button: 0, ctrlKey: false, pointerType: 'mouse' });
354+
fireEvent.click(within(await screen.findByRole('menu')).getByTestId(`studio-item-tool-${tool}`));
355+
return screen.findByTestId(`studio-item-tool-sheet-${tool}`);
356+
};
357+
const close = async () => {
358+
fireEvent.keyDown(document.activeElement ?? document.body, { key: 'Escape' });
359+
await waitFor(() => expect(screen.queryByRole('dialog')).toBeNull());
360+
};
361+
362+
const layers = await pick('layers');
363+
expect(await within(layers).findByRole('tab', { name: t('engine.layers.diff', 'en') })).toBeInTheDocument();
364+
expect(mockClient.layered).toHaveBeenCalledWith('object', 'showcase_account', { packageId: PKG });
365+
await close();
366+
367+
const refs = await pick('references');
368+
expect(await within(refs).findByText('all_accounts')).toBeInTheDocument();
369+
expect(mockClient.references).toHaveBeenCalledWith('object', 'showcase_account');
370+
await close();
371+
372+
const history = await pick('history');
373+
expect(await within(history).findAllByTitle(ROLLBACK)).toHaveLength(2);
374+
await close();
375+
376+
await pick('audit');
377+
await waitFor(() => expect(mockClient.audit).toHaveBeenCalledWith('object', 'showcase_account', { limit: 100 }));
378+
await close();
379+
380+
const source = await pick('source');
381+
const editor = await within(source).findByRole('textbox', { name: 'JSON source' }, { timeout: 8000 });
382+
expect((editor as HTMLTextAreaElement).value).toContain('"name": "showcase_account"');
383+
});
384+
});

0 commit comments

Comments
 (0)