Skip to content

Commit 457e190

Browse files
authored
Merge pull request #2922 from kev1n77/fmy/ui-main
fix(web): align provider dialogs and add workspace composition coverage
2 parents 64008cd + fb71a90 commit 457e190

11 files changed

Lines changed: 154 additions & 24 deletions

File tree

‎design-system/apps/design-lab/src/i18n/messages.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ export const enUSMessages = {
208208
"patterns.navigation.description": "Compact 30-pixel rows, grouped destinations, disclosure, search, and persistent device status.",
209209
"patterns.navigation.search": "Search navigation",
210210
"patterns.navigation.workspace": "Workspace",
211+
"patterns.workspace.parent": "Parent directory",
212+
"patterns.workspace.name": "Workspace name",
213+
"patterns.workspace.fullPath": "Full path",
211214
"patterns.navigation.projects": "Projects",
212215
"patterns.navigation.tools": "Development tools",
213216
"patterns.navigation.status": "Keep primary navigation quiet; reveal secondary destinations only when they are needed.",
@@ -951,6 +954,9 @@ export const zhCNMessages = {
951954
"patterns.navigation.description": "紧凑的 30 像素行、分组目标、展开区域、搜索与常驻设备状态。",
952955
"patterns.navigation.search": "搜索导航",
953956
"patterns.navigation.workspace": "工作区",
957+
"patterns.workspace.parent": "父文件夹",
958+
"patterns.workspace.name": "工作区名称",
959+
"patterns.workspace.fullPath": "完整路径",
954960
"patterns.navigation.projects": "项目",
955961
"patterns.navigation.tools": "开发工具",
956962
"patterns.navigation.status": "保持主导航安静,只在需要时展开次要目标。",
@@ -1485,6 +1491,9 @@ export const zhCNMessages = {
14851491

14861492
export const zhTWMessages = {
14871493
...zhCNMessages,
1494+
"patterns.workspace.parent": "父資料夾",
1495+
"patterns.workspace.name": "工作區名稱",
1496+
"patterns.workspace.fullPath": "完整路徑",
14881497
"component.Combobox.description": "支援搜尋、分組、單選或多選、自訂值、標籤及非同步狀態的選擇器。",
14891498
"patterns.provider.title": "供應商設定對話框",
14901499
"patterns.provider.description": "組合連線參數、搜尋多選、自訂模型、可展開的模型詳情和固定底部操作區。",

‎design-system/apps/design-lab/src/pages/PatternsPage.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ import {
3535
type TokenOverrides,
3636
} from "@openbitfun/ui";
3737
import { useI18n, type MessageKey } from "../i18n";
38-
import { FormTypographyPattern, NestedMenuPattern, ProviderConfigurationPattern, SceneToolbarPattern } from "./ReferencePatterns";
38+
import { FormTypographyPattern, NestedMenuPattern, ProviderConfigurationPattern, SceneToolbarPattern, WorkspaceConfigurationPattern } from "./ReferencePatterns";
3939

4040
interface PatternsPageProps {
4141
colorScheme: ColorScheme;
@@ -179,6 +179,7 @@ export function PatternsPage({ colorScheme, contrast, density, tokenOverrides }:
179179
</PatternSection>
180180
<PatternSection description={t("patterns.provider.description")} index="05" title={t("patterns.provider.title")}>
181181
<ProviderConfigurationPattern />
182+
<WorkspaceConfigurationPattern />
182183
</PatternSection>
183184
<PatternSection description={t("patterns.toolbar.description")} index="06" title={t("patterns.toolbar.title")}>
184185
<SceneToolbarPattern />

‎design-system/apps/design-lab/src/pages/ReferencePatterns.tsx‎

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useRef, useState } from "react";
1+
import { useId, useRef, useState } from "react";
22
import {
33
Button,
44
Card,
@@ -61,10 +61,10 @@ export function ProviderConfigurationPattern() {
6161
const [open, setOpen] = useState(false);
6262
const [revision, setRevision] = useState(0);
6363
const [saved, setSaved] = useState(false);
64-
const footer = (close: () => void) => <CardFooter align="center">
64+
const footer = (close: () => void) => <>
6565
<Button variant="fill" onClick={close}>{t("components.preview.modalCancel")}</Button>
6666
<Button variant="primary" onClick={() => { setSaved(true); setOpen(false); }}>{t("components.preview.modalSave")}</Button>
67-
</CardFooter>;
67+
</>;
6868

6969
return <div className="pattern-provider" data-openbitfun-pattern="provider-configuration">
7070
<div className="pattern-demo-actions">
@@ -74,31 +74,78 @@ export function ProviderConfigurationPattern() {
7474
<Card appearance="raised" padding="md" gap="lg" radius="lg">
7575
<PageHeader level={3} size="md" title={t("components.preview.modalTitle")} />
7676
<ProviderFields key={revision} />
77-
{footer(() => { setRevision(value => value + 1); setSaved(false); })}
77+
<CardFooter align="center">{footer(() => { setRevision(value => value + 1); setSaved(false); })}</CardFooter>
7878
</Card>
7979
<Dialog
8080
open={open}
8181
onOpenChange={(nextOpen) => { if (!nextOpen) (() => setOpen(false))(); }}
82-
size="md"
82+
size="xl"
8383
>
8484
<DialogHeader>
8585
<DialogHeading>
8686
<DialogTitle>{t("components.preview.modalTitle")}</DialogTitle>
8787
</DialogHeading>
8888
<DialogClose aria-label={t("components.preview.close")} />
8989
</DialogHeader>
90-
<DialogBody inset="none">
90+
<DialogBody>
9191
<div className="pattern-provider-modal">
9292
<ProviderFields />
9393
</div>
9494
</DialogBody>
95-
<DialogFooter>{footer(() => setOpen(false))}</DialogFooter>
95+
<DialogFooter appearance="floating">{footer(() => setOpen(false))}</DialogFooter>
9696
</Dialog>
9797
</>
9898
<p className="pattern-feedback" role="status">{t(saved ? "patterns.provider.saved" : "patterns.provider.previewOnly")}</p>
9999
</div>;
100100
}
101101

102+
export function WorkspaceConfigurationPattern() {
103+
const { t } = useI18n();
104+
const formId = useId();
105+
const [open, setOpen] = useState(false);
106+
const [name, setName] = useState("");
107+
const [parent, setParent] = useState("/workspaces");
108+
const [savedPath, setSavedPath] = useState("");
109+
const fullPath = name.trim() ? `${parent}/${name.trim()}` : "";
110+
return <div data-openbitfun-pattern="workspace-configuration">
111+
<Button size="sm" onClick={() => setOpen(true)}>{t("patterns.actions.newProject")}</Button>
112+
<Dialog open={open} onOpenChange={() => setOpen(false)} size="sm">
113+
<DialogHeader>
114+
<DialogHeading><DialogTitle>{t("patterns.actions.newProject")}</DialogTitle></DialogHeading>
115+
<DialogClose />
116+
</DialogHeader>
117+
<DialogBody>
118+
<form id={formId} onSubmit={(event) => {
119+
event.preventDefault();
120+
if (!name.trim()) return;
121+
setSavedPath(fullPath);
122+
setOpen(false);
123+
}}>
124+
<FieldGroup appearance="subtle" dividers>
125+
<FieldRow><Field label={t("patterns.workspace.parent")} controlWidth="fill">
126+
<Select size="sm" value={parent} onValueChange={(value) => setParent(String(value))} options={[
127+
{ value: "/workspaces", label: "/workspaces" },
128+
{ value: "/workspaces/design-system/long-parent-directory", label: "/workspaces/design-system/long-parent-directory" },
129+
]} />
130+
</Field></FieldRow>
131+
<FieldRow><Field label={t("patterns.workspace.name")} controlWidth="fill">
132+
<Input size="sm" value={name} onChange={(event) => setName(event.target.value)} autoFocus />
133+
</Field></FieldRow>
134+
{fullPath && <FieldRow><Field label={t("patterns.workspace.fullPath")} controlWidth="fill">
135+
<span className="pattern-workspace-path">{fullPath}</span>
136+
</Field></FieldRow>}
137+
</FieldGroup>
138+
</form>
139+
</DialogBody>
140+
<DialogFooter>
141+
<Button size="sm" variant="fill" onClick={() => setOpen(false)}>{t("components.preview.modalCancel")}</Button>
142+
<Button size="sm" variant="primary" type="submit" form={formId} disabled={!name.trim()}>{t("patterns.actions.newProject")}</Button>
143+
</DialogFooter>
144+
</Dialog>
145+
{savedPath && <p className="pattern-workspace-path" role="status">{savedPath}</p>}
146+
</div>;
147+
}
148+
102149
function ProviderFields() {
103150
const { t } = useI18n();
104151
const [models, setModels] = useState(["glm-5.2", "glm-4.7"]);

‎design-system/apps/design-lab/src/styles.css‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2987,6 +2987,11 @@ body,
29872987
container-type: inline-size;
29882988
}
29892989

2990+
.pattern-workspace-path {
2991+
overflow-wrap: anywhere;
2992+
color: var(--openbitfun-color-content-secondary);
2993+
}
2994+
29902995
.pattern-provider-fields [data-openbitfun-component="disclosure"] [data-openbitfun-part="content-inner"] {
29912996
display: grid;
29922997
gap: var(--openbitfun-space-3);

‎design-system/packages/design-tokens/src/system.tokens.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -870,7 +870,7 @@
870870
"maxInlineSizeSmall": { "$value": "420px" },
871871
"maxInlineSizeMedium": { "$value": "560px" },
872872
"maxInlineSizeLarge": { "$value": "600px" },
873-
"maxInlineSizeXlarge": { "$value": "720px" },
873+
"maxInlineSizeXlarge": { "$value": "800px" },
874874
"maxInlineSizeXxlarge": { "$value": "960px" },
875875
"maxInlineSizeWide": { "$value": "1200px" }
876876
}

‎design-system/packages/ui/README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -417,3 +417,5 @@ Pattern includes a scrolling toggle for keyboard and submenu verification.
417417
Compact tabs use `size="sm"` (30px, 14px icons, 4px icon gap); standard tabs retain 40px and 16px icons. Tabs share the outline-button surface contract and keep selection separate from pointer press. `SegmentedControl size="md"` uses a borderless 36px bar with 30px segments, 3px inset, 4px gaps and 12px segment padding. The default `sm` bar keeps its 28px outer height; separate pills retain their existing heights. Mobile controls own their touch geometry independently.
418418

419419
Dialog titles use 24px bold type with their own 29px line box and normal tracking. `DialogHeader` and `DialogFooter` omit separators by default; pass `separator` for a deliberate divider. A direct `DialogBody` sibling of `DialogFooter appearance="floating"` owns the trailing scroll inset automatically. The floating footer provides the 68px centered action area and a masked blur/gradient using the current theme surface; reduced transparency and forced colors use an opaque fallback. Keep scrollable form content inside `DialogBody` instead of adding a second viewport with independent footer spacing.
420+
421+
Extra-large (`xl`) dialogs have an 800px maximum width and continue shrinking within the viewport gutter. Provider editing uses the floating footer; small workspace creation retains its attached footer and existing button/input sizes. The Lab workspace pattern uses local sample paths and callbacks only.

‎src/apps/data-migrator/ui/generated/design-system.css‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,7 +349,7 @@
349349
--openbitfun-overlay-dialog-max-inline-size-medium: 560px;
350350
--openbitfun-overlay-dialog-max-inline-size-small: 420px;
351351
--openbitfun-overlay-dialog-max-inline-size-wide: 1200px;
352-
--openbitfun-overlay-dialog-max-inline-size-xlarge: 720px;
352+
--openbitfun-overlay-dialog-max-inline-size-xlarge: 800px;
353353
--openbitfun-overlay-dialog-max-inline-size-xxlarge: 960px;
354354
--openbitfun-overlay-dialog-scrollbar-width: var(--openbitfun-scrollbar-width);
355355
--openbitfun-overlay-dialog-surface-radius: 28px;
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// @vitest-environment jsdom
2+
import React, { act } from 'react';
3+
import { createRoot, type Root } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5+
import { NewProjectDialog } from './NewProjectDialog';
6+
7+
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
8+
const { pickDirectory } = vi.hoisted(() => ({ pickDirectory: vi.fn() }));
9+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) }));
10+
vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) }));
11+
vi.mock('@/infrastructure/peer-device/pickWorkspaceDirectory', () => ({ pickWorkspaceDirectory: pickDirectory }));
12+
13+
describe('NewProjectDialog composition', () => {
14+
let root: Root;
15+
let host: HTMLDivElement;
16+
const close = vi.fn();
17+
const button = (label: string) => [...document.querySelectorAll('button')].find((item) => item.textContent === label)!;
18+
const nameInput = () => document.querySelector<HTMLInputElement>('input:not([readonly])')!;
19+
const enterName = (value: string) => act(() => {
20+
const input = nameInput();
21+
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value);
22+
input.dispatchEvent(new Event('input', { bubbles: true }));
23+
});
24+
25+
beforeEach(() => {
26+
vi.clearAllMocks();
27+
host = document.createElement('div');
28+
document.body.append(host);
29+
root = createRoot(host);
30+
});
31+
afterEach(() => {
32+
act(() => root.unmount());
33+
host.remove();
34+
});
35+
36+
it('locks actions while creating and retains the form when creation fails', async () => {
37+
let rejectCreate!: (reason: Error) => void;
38+
const pending = new Promise<void>((_resolve, reject) => { rejectCreate = reject; });
39+
const confirm = vi.fn(() => pending);
40+
await act(async () => root.render(<NewProjectDialog isOpen defaultParentPath="/srv/workspaces" onClose={close} onConfirm={confirm} />));
41+
expect(button('newProject.cancel').dataset.openbitfunVariant).toBe('fill');
42+
expect(button('newProject.create').dataset.openbitfunVariant).toBe('primary');
43+
enterName(' example-project ');
44+
await act(async () => { button('newProject.create').click(); });
45+
expect(confirm).toHaveBeenCalledWith('/srv/workspaces', 'example-project');
46+
expect(button('newProject.cancel').disabled).toBe(true);
47+
expect(button('newProject.select').disabled).toBe(true);
48+
expect(nameInput().disabled).toBe(true);
49+
act(() => button('newProject.cancel').click());
50+
expect(close).not.toHaveBeenCalled();
51+
await act(async () => { rejectCreate(new Error('Directory unavailable')); });
52+
expect(document.querySelector('[role="alert"]')?.textContent).toContain('Directory unavailable');
53+
expect(nameInput().value).toBe(' example-project ');
54+
expect(button('newProject.create').disabled).toBe(false);
55+
expect(close).not.toHaveBeenCalled();
56+
});
57+
58+
it('delegates directory selection to the peer-aware picker and preserves its path', async () => {
59+
pickDirectory.mockResolvedValue('/srv/remote workspace');
60+
const confirm = vi.fn(async () => {});
61+
await act(async () => root.render(<NewProjectDialog isOpen defaultParentPath="/srv" onClose={close} onConfirm={confirm} />));
62+
await act(async () => { button('newProject.select').click(); });
63+
expect(pickDirectory).toHaveBeenCalledWith({ title: 'newProject.selectParentDirectory', defaultPath: '/srv' });
64+
enterName('project');
65+
expect(document.querySelector('[data-openbitfun-part="preview"]')?.textContent).toContain('/srv/remote workspace/project');
66+
await act(async () => { button('newProject.create').click(); });
67+
expect(confirm).toHaveBeenCalledWith('/srv/remote workspace', 'project');
68+
expect(close).toHaveBeenCalledTimes(1);
69+
});
70+
});

‎src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ describe('ModelSettingsPage dialog presentation', () => {
2727
expect(editorDialog).toContain('size="xl"');
2828
expect(editorDialog).not.toContain('size="2xl"');
2929
expect(editorDialog).toMatch(
30-
/\{!reasoningPanelDraft && \(\s*<DialogFooter>/,
30+
/\{!reasoningPanelDraft && \(\s*<DialogFooter appearance="floating">/,
3131
);
32-
expect(editorDialog).not.toContain('appearance="floating"');
32+
expect(editorDialog).toContain('appearance="floating"');
3333
expect(editorDialog).toContain(
3434
'<Button variant="fill" size="sm" onClick={requestCloseEditingModal} disabled={isEditorSaving}>',
3535
);
@@ -39,6 +39,8 @@ describe('ModelSettingsPage dialog presentation', () => {
3939
expect(editorDialog).toContain('<DialogClose disabled={isEditorSaving} />');
4040
expect(editorDialog).toContain('loading={isEditorSaving}');
4141
expect(editingForm.match(/fieldSurface="default"/g)).toHaveLength(2);
42+
expect(editingForm).not.toContain('<ScrollArea');
43+
expect(editingForm).toContain('className="openbitfun-model-settings__form-content"');
4244
expect(editorDialog).not.toContain('openbitfun-model-settings__editor-dialog-footer');
4345
expect(editorDialog).not.toContain('openbitfun-model-settings__editor-dialog-cancel');
4446
expect(styles).toMatch(

‎src/web-ui/src/infrastructure/config/components/ModelSettingsPage.scss‎

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,17 +1415,11 @@
14151415
&__form--modal {
14161416
display: flex;
14171417
flex-direction: column;
1418-
flex: 1;
1419-
min-height: 0;
1420-
overflow: hidden;
1418+
flex: 0 0 auto;
14211419
}
14221420

1423-
&__form-scrollable {
1424-
flex: 1 1 auto;
1425-
min-height: 0;
1426-
overflow-x: hidden;
1427-
padding: var(--openbitfun-space-5) var(--openbitfun-space-6);
1428-
scroll-padding-block: var(--openbitfun-space-5);
1421+
&__form-content {
1422+
padding-inline: var(--openbitfun-space-6);
14291423
display: flex;
14301424
flex-direction: column;
14311425
gap: var(--openbitfun-space-6);

0 commit comments

Comments
 (0)