From 945259f0485608d22d3995473199a9f94ee344cc Mon Sep 17 00:00:00 2001 From: Anso Date: Mon, 4 May 2026 11:17:29 -0400 Subject: [PATCH] refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4) (#908) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(frontend): migrate CreateStackDialog to Modal chrome system (D-4) Swap raw shadcn Dialog/DialogContent/DialogHeader/DialogFooter for the Modal/ModalHeader/ModalBody/ModalFooter primitives shipped in D-1 and introduce an inline ModeRail to replace the TabsHighlight chip. The new chrome carries the cyan rail, mono kicker (STACKS · NEW), italic serif title, and contextual footer hints per mode (ALPHANUMERIC · HYPHENS, HTTPS REPOS ONLY, CONVERT FIRST / YAML READY + line-count accent). ModeRail implements the full WAI-ARIA tabs pattern: aria-selected on the active tab, aria-controls/role=tabpanel/aria-labelledby linkage to each mode panel, roving tabIndex (active=0, inactive=-1), and ArrowLeft / ArrowRight / Home / End keyboard navigation matching the contract that the prior shadcn Tabs primitive provided. Each mode also gains an explicit Cancel button alongside the existing primary action, the empty-mode primary button is now disabled until the stack name is non-empty, and the ModeRail disables itself while a Git or docker-run create is in flight. The async handlers (handleCreateStack, handleCreateStackFromGit, handleConvertDockerRun, handleCreateStackFromDockerRun) and form-reset helpers are unchanged; this PR is structural plus the segmented-control redesign called out in the migration tracker as the D-4 risk note. * test(e2e): align git-sources spec with new CreateStackDialog title D-4 renamed the dialog title from "Create New Stack" to "New stack". The shared openCreateStackDialog helper in git-sources.spec.ts was still asserting the old literal, so three tests in the "Create stack from Git" group failed at the helper's first assertion. Update the assertion to match the shipped title and refresh the two stale references in docs/features/stack-management.mdx so the docs stay in sync with the UI copy. * test(e2e): use dialog accessible name in openCreateStackDialog helper The previous helper used getByText('New stack') which matched two elements: the dialog title h2 and the sr-only description (which starts "Create a new stack: empty, ..." and contains the substring). Playwright fails with a strict mode violation. Switch to getByRole('dialog', { name: 'New stack' }), which asserts the dialog by its accessible name (provided by DialogTitle via the Radix aria-labelledby wiring). One match, more precise, and independent of any future description copy. --- docs/features/stack-management.mdx | 4 +- e2e/git-sources.spec.ts | 2 +- .../EditorLayout/CreateStackDialog.tsx | 420 +++++++++++------- 3 files changed, 256 insertions(+), 170 deletions(-) diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 2fbfa923..95b84090 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -6,7 +6,7 @@ description: Create, deploy, control, and remove Docker Compose stacks. A **stack** in Sencho is a Docker Compose project: a directory inside your `COMPOSE_DIR` that contains at least a `compose.yaml` (or `docker-compose.yml`) file. Sencho automatically discovers every subdirectory as a stack. - Create New Stack dialog + New stack dialog ## Creating a stack @@ -24,7 +24,7 @@ Sencho creates a new directory inside `COMPOSE_DIR` with a blank `compose.yaml` If you have a `docker run` command handy (from a README, a forum post, or your shell history), Sencho can turn it into a ready-to-deploy `compose.yaml` without hand translation. -Open the **Create New Stack** dialog and switch to the **From Docker Run** tab. +Open the **New stack** dialog and switch to the **From Docker Run** tab. Create stack dialog on the From Docker Run tab diff --git a/e2e/git-sources.spec.ts b/e2e/git-sources.spec.ts index c6a9a29d..cf26d2fd 100644 --- a/e2e/git-sources.spec.ts +++ b/e2e/git-sources.spec.ts @@ -210,7 +210,7 @@ async function deleteCreateFromGitStack(page: Page) { async function openCreateStackDialog(page: Page) { await page.getByRole('button', { name: 'Create Stack' }).click(); - await expect(page.getByRole('dialog').getByText('Create New Stack')).toBeVisible({ timeout: 5_000 }); + await expect(page.getByRole('dialog', { name: 'New stack' })).toBeVisible({ timeout: 5_000 }); } test.describe('Create stack from Git', () => { diff --git a/frontend/src/components/EditorLayout/CreateStackDialog.tsx b/frontend/src/components/EditorLayout/CreateStackDialog.tsx index 5ab200ee..f20237a4 100644 --- a/frontend/src/components/EditorLayout/CreateStackDialog.tsx +++ b/frontend/src/components/EditorLayout/CreateStackDialog.tsx @@ -1,15 +1,6 @@ -import { useState } from 'react'; -import { Plus, GitBranch, FileCode2, Loader2 } from 'lucide-react'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from '../ui/dialog'; -import { Tabs, TabsList, TabsTrigger, TabsHighlight, TabsHighlightItem } from '../ui/tabs'; -import { springs } from '@/lib/motion'; +import { useRef, useState, type KeyboardEvent } from 'react'; +import { Plus, GitBranch, FileCode2, Loader2, type LucideIcon } from 'lucide-react'; +import { Modal, ModalHeader, ModalBody, ModalFooter } from '../ui/modal'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; import { Label } from '../ui/label'; @@ -18,6 +9,7 @@ import { Checkbox } from '../ui/checkbox'; import { GitSourceFields, type ApplyMode } from '../stack/GitSourceFields'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; +import { cn } from '@/lib/utils'; export interface CreateStackDialogProps { open: boolean; @@ -26,8 +18,19 @@ export interface CreateStackDialogProps { onStacksChanged: () => void | Promise; } +type CreateMode = 'empty' | 'git' | 'docker-run'; + +const MODES: ReadonlyArray<{ id: CreateMode; label: string; icon: LucideIcon }> = [ + { id: 'empty', label: 'Empty', icon: Plus }, + { id: 'git', label: 'From Git', icon: GitBranch }, + { id: 'docker-run', label: 'From Docker Run', icon: FileCode2 }, +]; + +const tabId = (m: CreateMode) => `create-stack-tab-${m}`; +const panelId = (m: CreateMode) => `create-stack-panel-${m}`; + export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacksChanged }: CreateStackDialogProps) { - const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty'); + const [createMode, setCreateMode] = useState('empty'); const [newStackName, setNewStackName] = useState(''); const [dockerRunInput, setDockerRunInput] = useState(''); const [convertedYaml, setConvertedYaml] = useState(null); @@ -64,7 +67,6 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks const handleCreateStack = async () => { if (!newStackName.trim()) return; - // Send stackName directly (no .yml extension - backend creates directory) const stackName = newStackName.trim(); try { const response = await apiFetch('/stacks', { @@ -263,8 +265,11 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks } }; + const busy = creatingFromGit || creatingFromDockerRun; + return ( - { onOpenChange(o); @@ -275,106 +280,99 @@ export function CreateStackDialog({ open, onOpenChange, onStackCreated, onStacks } }} > - - - Create New Stack - - Create a new stack: empty, cloned from a Git repository, or converted from a docker run command. - - + + -
- setCreateMode(v as 'empty' | 'git' | 'docker-run')}> - - - - - - Empty - - - - - - From Git - - - - - - From Docker Run - - - - - -
- - {createMode === 'empty' && ( - <> -
+ {createMode === 'empty' && ( +
+ +
setNewStackName(e.target.value)} + autoFocus />
- - - - - )} - {createMode === 'git' && ( - <> - -
-
- - setNewStackName(e.target.value)} - disabled={creatingFromGit} - /> -
+ + onOpenChange(false)}> + Cancel + + } + primary={ + + } + /> +
+ )} - + + +
+ + setNewStackName(e.target.value)} disabled={creatingFromGit} - repoUrl={gitRepoUrl} - branch={gitBranch} - composePath={gitComposePath} - syncEnv={gitSyncEnv} - authType={gitAuthType} - token={gitToken} - hasStoredToken={false} - applyMode={gitApplyMode} - onRepoUrlChange={setGitRepoUrl} - onBranchChange={setGitBranch} - onComposePathChange={setGitComposePath} - onSyncEnvChange={setGitSyncEnv} - onAuthTypeChange={setGitAuthType} - onTokenChange={setGitToken} - onApplyModeChange={setGitApplyMode} /> - -
- setGitDeployNow(c === true)} - disabled={creatingFromGit} - /> - -
-
- + + + +
+ setGitDeployNow(c === true)} + disabled={creatingFromGit} + /> + +
+
+ + onOpenChange(false)} disabled={creatingFromGit}> + Cancel + + } + primary={ - - - )} - {createMode === 'docker-run' && ( - <> - -
-
- - setNewStackName(e.target.value)} - disabled={creatingFromDockerRun} - /> -
-
- -