From fb71a90085f391726c1ee53e68ab2cab5cd1f00a Mon Sep 17 00:00:00 2001 From: kev1n77 Date: Wed, 9 Sep 2026 01:01:06 +0800 Subject: [PATCH] fix(web): correct workspace and provider dialog composition --- .../apps/design-lab/src/i18n/messages.ts | 9 +++ .../design-lab/src/pages/PatternsPage.tsx | 3 +- .../src/pages/ReferencePatterns.tsx | 61 ++++++++++++++-- design-system/apps/design-lab/src/styles.css | 5 ++ .../design-tokens/src/system.tokens.json | 2 +- design-system/packages/ui/README.md | 2 + .../ui/generated/design-system.css | 2 +- .../NewProjectDialog.test.tsx | 70 +++++++++++++++++++ .../ModelSettingsDialog.presentation.test.ts | 6 +- .../config/components/ModelSettingsPage.scss | 12 +--- .../config/components/ModelSettingsPage.tsx | 6 +- 11 files changed, 154 insertions(+), 24 deletions(-) create mode 100644 src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx diff --git a/design-system/apps/design-lab/src/i18n/messages.ts b/design-system/apps/design-lab/src/i18n/messages.ts index 0537a227e5..22926047c5 100644 --- a/design-system/apps/design-lab/src/i18n/messages.ts +++ b/design-system/apps/design-lab/src/i18n/messages.ts @@ -208,6 +208,9 @@ export const enUSMessages = { "patterns.navigation.description": "Compact 30-pixel rows, grouped destinations, disclosure, search, and persistent device status.", "patterns.navigation.search": "Search navigation", "patterns.navigation.workspace": "Workspace", + "patterns.workspace.parent": "Parent directory", + "patterns.workspace.name": "Workspace name", + "patterns.workspace.fullPath": "Full path", "patterns.navigation.projects": "Projects", "patterns.navigation.tools": "Development tools", "patterns.navigation.status": "Keep primary navigation quiet; reveal secondary destinations only when they are needed.", @@ -951,6 +954,9 @@ export const zhCNMessages = { "patterns.navigation.description": "紧凑的 30 像素行、分组目标、展开区域、搜索与常驻设备状态。", "patterns.navigation.search": "搜索导航", "patterns.navigation.workspace": "工作区", + "patterns.workspace.parent": "父文件夹", + "patterns.workspace.name": "工作区名称", + "patterns.workspace.fullPath": "完整路径", "patterns.navigation.projects": "项目", "patterns.navigation.tools": "开发工具", "patterns.navigation.status": "保持主导航安静,只在需要时展开次要目标。", @@ -1485,6 +1491,9 @@ export const zhCNMessages = { export const zhTWMessages = { ...zhCNMessages, + "patterns.workspace.parent": "父資料夾", + "patterns.workspace.name": "工作區名稱", + "patterns.workspace.fullPath": "完整路徑", "component.Combobox.description": "支援搜尋、分組、單選或多選、自訂值、標籤及非同步狀態的選擇器。", "patterns.provider.title": "供應商設定對話框", "patterns.provider.description": "組合連線參數、搜尋多選、自訂模型、可展開的模型詳情和固定底部操作區。", diff --git a/design-system/apps/design-lab/src/pages/PatternsPage.tsx b/design-system/apps/design-lab/src/pages/PatternsPage.tsx index 393f9ef21a..10e5e2d033 100644 --- a/design-system/apps/design-lab/src/pages/PatternsPage.tsx +++ b/design-system/apps/design-lab/src/pages/PatternsPage.tsx @@ -35,7 +35,7 @@ import { type TokenOverrides, } from "@openbitfun/ui"; import { useI18n, type MessageKey } from "../i18n"; -import { FormTypographyPattern, NestedMenuPattern, ProviderConfigurationPattern, SceneToolbarPattern } from "./ReferencePatterns"; +import { FormTypographyPattern, NestedMenuPattern, ProviderConfigurationPattern, SceneToolbarPattern, WorkspaceConfigurationPattern } from "./ReferencePatterns"; interface PatternsPageProps { colorScheme: ColorScheme; @@ -179,6 +179,7 @@ export function PatternsPage({ colorScheme, contrast, density, tokenOverrides }: + diff --git a/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx b/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx index f3fdf8e8e1..e0fb144cb4 100644 --- a/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx +++ b/design-system/apps/design-lab/src/pages/ReferencePatterns.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useId, useRef, useState } from "react"; import { Button, Card, @@ -61,10 +61,10 @@ export function ProviderConfigurationPattern() { const [open, setOpen] = useState(false); const [revision, setRevision] = useState(0); const [saved, setSaved] = useState(false); - const footer = (close: () => void) => + const footer = (close: () => void) => <> - ; + ; return
@@ -74,12 +74,12 @@ export function ProviderConfigurationPattern() { - {footer(() => { setRevision(value => value + 1); setSaved(false); })} + {footer(() => { setRevision(value => value + 1); setSaved(false); })} { if (!nextOpen) (() => setOpen(false))(); }} - size="md" + size="xl" > @@ -87,18 +87,65 @@ export function ProviderConfigurationPattern() { - +
- {footer(() => setOpen(false))} + {footer(() => setOpen(false))}

{t(saved ? "patterns.provider.saved" : "patterns.provider.previewOnly")}

; } +export function WorkspaceConfigurationPattern() { + const { t } = useI18n(); + const formId = useId(); + const [open, setOpen] = useState(false); + const [name, setName] = useState(""); + const [parent, setParent] = useState("/workspaces"); + const [savedPath, setSavedPath] = useState(""); + const fullPath = name.trim() ? `${parent}/${name.trim()}` : ""; + return
+ + setOpen(false)} size="sm"> + + {t("patterns.actions.newProject")} + + + +
{ + event.preventDefault(); + if (!name.trim()) return; + setSavedPath(fullPath); + setOpen(false); + }}> + + + setName(event.target.value)} autoFocus /> + + {fullPath && + {fullPath} + } + +
+
+ + + + +
+ {savedPath &&

{savedPath}

} +
; +} + function ProviderFields() { const { t } = useI18n(); const [models, setModels] = useState(["glm-5.2", "glm-4.7"]); diff --git a/design-system/apps/design-lab/src/styles.css b/design-system/apps/design-lab/src/styles.css index 3c2b6ae4e8..b7a4bada7a 100644 --- a/design-system/apps/design-lab/src/styles.css +++ b/design-system/apps/design-lab/src/styles.css @@ -2987,6 +2987,11 @@ body, container-type: inline-size; } +.pattern-workspace-path { + overflow-wrap: anywhere; + color: var(--openbitfun-color-content-secondary); +} + .pattern-provider-fields [data-openbitfun-component="disclosure"] [data-openbitfun-part="content-inner"] { display: grid; gap: var(--openbitfun-space-3); diff --git a/design-system/packages/design-tokens/src/system.tokens.json b/design-system/packages/design-tokens/src/system.tokens.json index 079aff2ca9..cfed08c856 100644 --- a/design-system/packages/design-tokens/src/system.tokens.json +++ b/design-system/packages/design-tokens/src/system.tokens.json @@ -870,7 +870,7 @@ "maxInlineSizeSmall": { "$value": "420px" }, "maxInlineSizeMedium": { "$value": "560px" }, "maxInlineSizeLarge": { "$value": "600px" }, - "maxInlineSizeXlarge": { "$value": "720px" }, + "maxInlineSizeXlarge": { "$value": "800px" }, "maxInlineSizeXxlarge": { "$value": "960px" }, "maxInlineSizeWide": { "$value": "1200px" } } diff --git a/design-system/packages/ui/README.md b/design-system/packages/ui/README.md index 862fea78c6..ef599ff9a8 100644 --- a/design-system/packages/ui/README.md +++ b/design-system/packages/ui/README.md @@ -417,3 +417,5 @@ Pattern includes a scrolling toggle for keyboard and submenu verification. 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. 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. + +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. diff --git a/src/apps/data-migrator/ui/generated/design-system.css b/src/apps/data-migrator/ui/generated/design-system.css index 7289d3927d..f54a291fd0 100644 --- a/src/apps/data-migrator/ui/generated/design-system.css +++ b/src/apps/data-migrator/ui/generated/design-system.css @@ -349,7 +349,7 @@ --openbitfun-overlay-dialog-max-inline-size-medium: 560px; --openbitfun-overlay-dialog-max-inline-size-small: 420px; --openbitfun-overlay-dialog-max-inline-size-wide: 1200px; - --openbitfun-overlay-dialog-max-inline-size-xlarge: 720px; + --openbitfun-overlay-dialog-max-inline-size-xlarge: 800px; --openbitfun-overlay-dialog-max-inline-size-xxlarge: 960px; --openbitfun-overlay-dialog-scrollbar-width: var(--openbitfun-scrollbar-width); --openbitfun-overlay-dialog-surface-radius: 28px; diff --git a/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx new file mode 100644 index 0000000000..dc6b082535 --- /dev/null +++ b/src/web-ui/src/app/components/NewProjectDialog/NewProjectDialog.test.tsx @@ -0,0 +1,70 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NewProjectDialog } from './NewProjectDialog'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; +const { pickDirectory } = vi.hoisted(() => ({ pickDirectory: vi.fn() })); +vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); +vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn() }) })); +vi.mock('@/infrastructure/peer-device/pickWorkspaceDirectory', () => ({ pickWorkspaceDirectory: pickDirectory })); + +describe('NewProjectDialog composition', () => { + let root: Root; + let host: HTMLDivElement; + const close = vi.fn(); + const button = (label: string) => [...document.querySelectorAll('button')].find((item) => item.textContent === label)!; + const nameInput = () => document.querySelector('input:not([readonly])')!; + const enterName = (value: string) => act(() => { + const input = nameInput(); + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + + beforeEach(() => { + vi.clearAllMocks(); + host = document.createElement('div'); + document.body.append(host); + root = createRoot(host); + }); + afterEach(() => { + act(() => root.unmount()); + host.remove(); + }); + + it('locks actions while creating and retains the form when creation fails', async () => { + let rejectCreate!: (reason: Error) => void; + const pending = new Promise((_resolve, reject) => { rejectCreate = reject; }); + const confirm = vi.fn(() => pending); + await act(async () => root.render()); + expect(button('newProject.cancel').dataset.openbitfunVariant).toBe('fill'); + expect(button('newProject.create').dataset.openbitfunVariant).toBe('primary'); + enterName(' example-project '); + await act(async () => { button('newProject.create').click(); }); + expect(confirm).toHaveBeenCalledWith('/srv/workspaces', 'example-project'); + expect(button('newProject.cancel').disabled).toBe(true); + expect(button('newProject.select').disabled).toBe(true); + expect(nameInput().disabled).toBe(true); + act(() => button('newProject.cancel').click()); + expect(close).not.toHaveBeenCalled(); + await act(async () => { rejectCreate(new Error('Directory unavailable')); }); + expect(document.querySelector('[role="alert"]')?.textContent).toContain('Directory unavailable'); + expect(nameInput().value).toBe(' example-project '); + expect(button('newProject.create').disabled).toBe(false); + expect(close).not.toHaveBeenCalled(); + }); + + it('delegates directory selection to the peer-aware picker and preserves its path', async () => { + pickDirectory.mockResolvedValue('/srv/remote workspace'); + const confirm = vi.fn(async () => {}); + await act(async () => root.render()); + await act(async () => { button('newProject.select').click(); }); + expect(pickDirectory).toHaveBeenCalledWith({ title: 'newProject.selectParentDirectory', defaultPath: '/srv' }); + enterName('project'); + expect(document.querySelector('[data-openbitfun-part="preview"]')?.textContent).toContain('/srv/remote workspace/project'); + await act(async () => { button('newProject.create').click(); }); + expect(confirm).toHaveBeenCalledWith('/srv/remote workspace', 'project'); + expect(close).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts b/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts index 7135e3715c..572b8ba948 100644 --- a/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts +++ b/src/web-ui/src/infrastructure/config/components/ModelSettingsDialog.presentation.test.ts @@ -27,9 +27,9 @@ describe('ModelSettingsPage dialog presentation', () => { expect(editorDialog).toContain('size="xl"'); expect(editorDialog).not.toContain('size="2xl"'); expect(editorDialog).toMatch( - /\{!reasoningPanelDraft && \(\s*/, + /\{!reasoningPanelDraft && \(\s*/, ); - expect(editorDialog).not.toContain('appearance="floating"'); + expect(editorDialog).toContain('appearance="floating"'); expect(editorDialog).toContain( '