mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
Fix template project creation flow (#2399)
This commit is contained in:
parent
a5e43ae2a4
commit
f4b8fbece2
8 changed files with 205 additions and 25 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { useAnalytics } from './analytics/provider';
|
||||
import { trackProjectCreateResult } from './analytics/events';
|
||||
import { detectClientType } from './analytics/identity';
|
||||
|
|
@ -17,7 +18,7 @@ import { PetOverlay, type PetTaskCenter } from './components/pet/PetOverlay';
|
|||
import { buildPetTaskCenter } from './components/pet/taskCenter';
|
||||
import { migrateCustomPetAtlas } from './components/pet/pets';
|
||||
import { ProjectView } from './components/ProjectView';
|
||||
import { WorkspaceTabsBar } from './components/WorkspaceTabsBar';
|
||||
import { openWorkspaceTab, WorkspaceTabsBar } from './components/WorkspaceTabsBar';
|
||||
import {
|
||||
DesignSystemCreationFlow,
|
||||
DesignSystemDetailView,
|
||||
|
|
@ -769,7 +770,7 @@ export function App() {
|
|||
requestId?: string;
|
||||
pendingFiles?: File[];
|
||||
},
|
||||
) => {
|
||||
): Promise<boolean> => {
|
||||
// Honor an explicit `null` design system — the create panel defaults
|
||||
// to "None" for every kind now, and the user expects that to land
|
||||
// as a no-design-system project rather than silently inheriting the
|
||||
|
|
@ -809,7 +810,7 @@ export function App() {
|
|||
},
|
||||
{ requestId: input.requestId },
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
const pendingFiles = Array.isArray(input.pendingFiles)
|
||||
? input.pendingFiles.filter((file): file is File => file instanceof File)
|
||||
|
|
@ -870,15 +871,20 @@ export function App() {
|
|||
appliedPluginSnapshotId: result.appliedPluginSnapshotId,
|
||||
}
|
||||
: result.project;
|
||||
setProjects((curr) => [
|
||||
project,
|
||||
...curr.filter((p) => p.id !== project.id),
|
||||
]);
|
||||
navigate({
|
||||
flushSync(() => {
|
||||
setProjects((curr) => [
|
||||
project,
|
||||
...curr.filter((p) => p.id !== project.id),
|
||||
]);
|
||||
});
|
||||
const projectRoute = {
|
||||
kind: 'project',
|
||||
projectId: project.id,
|
||||
fileName: null,
|
||||
});
|
||||
} as const;
|
||||
openWorkspaceTab(projectRoute);
|
||||
navigate(projectRoute);
|
||||
return true;
|
||||
},
|
||||
[analytics.track],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -113,14 +113,56 @@ function defaultPluginInputsForCreate(
|
|||
input: CreateInput,
|
||||
pluginId: string | null,
|
||||
): Record<string, unknown> | null {
|
||||
if (pluginId !== 'od-media-generation') return null;
|
||||
const kind = input.metadata.kind;
|
||||
const projectName = input.name.trim();
|
||||
|
||||
if (pluginId === 'example-web-prototype') {
|
||||
return {
|
||||
artifactKind: input.metadata.includeLandingPage
|
||||
? 'landing page'
|
||||
: 'web prototype',
|
||||
fidelity: input.metadata.fidelity ?? 'high-fidelity',
|
||||
audience: 'product evaluators',
|
||||
designSystem: 'the active project design system',
|
||||
template: input.metadata.templateLabel ?? 'the bundled web prototype seed',
|
||||
};
|
||||
}
|
||||
|
||||
if (pluginId === 'example-simple-deck') {
|
||||
return {
|
||||
deckType: 'pitch deck',
|
||||
topic: projectName || 'the user brief',
|
||||
audience: 'decision makers',
|
||||
slideCount: 10,
|
||||
speakerNotes: input.metadata.speakerNotes
|
||||
? 'include speaker notes'
|
||||
: 'no speaker notes',
|
||||
designSystem: 'the active project design system',
|
||||
};
|
||||
}
|
||||
|
||||
if (pluginId === 'od-new-generation') {
|
||||
const templateLabel = input.metadata.templateLabel?.trim();
|
||||
const artifactKind =
|
||||
kind === 'template'
|
||||
? 'artifact based on a saved template'
|
||||
: kind === 'other'
|
||||
? 'custom design artifact'
|
||||
: `${kind} artifact`;
|
||||
return {
|
||||
artifactKind,
|
||||
audience: 'product and design reviewers',
|
||||
topic: templateLabel || projectName || 'the user brief',
|
||||
};
|
||||
}
|
||||
|
||||
if (pluginId !== 'od-media-generation') return null;
|
||||
if (kind !== 'image' && kind !== 'video' && kind !== 'audio') return null;
|
||||
|
||||
const promptTemplate = input.metadata.promptTemplate;
|
||||
const subject =
|
||||
promptTemplate?.prompt?.trim()
|
||||
|| input.name.trim()
|
||||
|| projectName
|
||||
|| promptTemplate?.title?.trim()
|
||||
|| `${kind} concept`;
|
||||
const style =
|
||||
|
|
@ -250,7 +292,7 @@ interface Props {
|
|||
autoSendFirstMessage?: boolean;
|
||||
pendingFiles?: File[];
|
||||
},
|
||||
) => void;
|
||||
) => Promise<boolean> | boolean | void;
|
||||
onCreatePluginShareProject: (
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
|
|
@ -459,7 +501,7 @@ export function EntryShell({
|
|||
// single row without touching the form.
|
||||
const pluginId = defaultPluginIdForKind(input.metadata);
|
||||
const pluginInputs = defaultPluginInputsForCreate(input, pluginId);
|
||||
onCreateProject({
|
||||
return onCreateProject({
|
||||
...input,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
...(pluginInputs ? { pluginInputs } : {}),
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ interface Props {
|
|||
autoSendFirstMessage?: boolean;
|
||||
pendingFiles?: File[];
|
||||
},
|
||||
) => void;
|
||||
) => Promise<boolean> | boolean | void;
|
||||
onCreatePluginShareProject: (
|
||||
pluginId: string,
|
||||
action: PluginShareAction,
|
||||
|
|
|
|||
|
|
@ -5,10 +5,10 @@
|
|||
// (prototype / live-artifact / deck / template / image / video /
|
||||
// audio / other) and their connector / template / design-system
|
||||
// pickers carry over without duplication. The modal closes itself
|
||||
// when the panel calls onCreate (success path) or when the user
|
||||
// when the panel calls onCreate and it completes (success path) or when the user
|
||||
// clicks the backdrop / Esc.
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { ConnectorDetail } from '@open-design/contracts';
|
||||
import type {
|
||||
DesignSystemSummary,
|
||||
|
|
@ -32,7 +32,7 @@ interface Props {
|
|||
connectors?: ConnectorDetail[];
|
||||
connectorsLoading?: boolean;
|
||||
loading?: boolean;
|
||||
onCreate: (input: CreateInput) => void;
|
||||
onCreate: (input: CreateInput & { requestId?: string }) => Promise<boolean> | boolean | void;
|
||||
onImportClaudeDesign?: (file: File) => Promise<void> | void;
|
||||
onImportFolder?: (baseDir: string) => Promise<void> | void;
|
||||
onOpenConnectorsTab?: () => void;
|
||||
|
|
@ -60,15 +60,17 @@ export function NewProjectModal({
|
|||
initialTab,
|
||||
}: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Escape' && !creating) onClose();
|
||||
};
|
||||
document.addEventListener('keydown', onKey);
|
||||
return () => document.removeEventListener('keydown', onKey);
|
||||
}, [open, onClose]);
|
||||
}, [creating, open, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
|
@ -81,11 +83,31 @@ export function NewProjectModal({
|
|||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCreating(false);
|
||||
setCreateError(null);
|
||||
closeRef.current?.focus();
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
async function handleCreate(input: CreateInput & { requestId?: string }) {
|
||||
if (creating) return;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
try {
|
||||
const result = await onCreate(input);
|
||||
if (result === false) {
|
||||
setCreateError('Could not create project. Please try again.');
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : 'Could not create project. Please try again.');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="new-project-modal-backdrop"
|
||||
|
|
@ -94,7 +116,7 @@ export function NewProjectModal({
|
|||
aria-label="New project"
|
||||
data-testid="new-project-modal"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
if (e.target === e.currentTarget && !creating) onClose();
|
||||
}}
|
||||
>
|
||||
<div className="new-project-modal">
|
||||
|
|
@ -105,6 +127,7 @@ export function NewProjectModal({
|
|||
type="button"
|
||||
className="new-project-modal__close"
|
||||
onClick={onClose}
|
||||
disabled={creating}
|
||||
aria-label="Close"
|
||||
title="Close (Esc)"
|
||||
>
|
||||
|
|
@ -122,16 +145,25 @@ export function NewProjectModal({
|
|||
{...(mediaProviders ? { mediaProviders } : {})}
|
||||
{...(connectors ? { connectors } : {})}
|
||||
{...(typeof connectorsLoading === 'boolean' ? { connectorsLoading } : {})}
|
||||
{...(typeof loading === 'boolean' ? { loading } : {})}
|
||||
loading={Boolean(loading) || creating}
|
||||
onCreate={(input) => {
|
||||
onCreate(input);
|
||||
onClose();
|
||||
void handleCreate(input);
|
||||
}}
|
||||
{...(onImportClaudeDesign ? { onImportClaudeDesign } : {})}
|
||||
{...(onImportFolder ? { onImportFolder } : {})}
|
||||
{...(onOpenConnectorsTab ? { onOpenConnectorsTab } : {})}
|
||||
{...(initialTab ? { initialTab } : {})}
|
||||
/>
|
||||
{creating ? (
|
||||
<div className="new-project-modal__status" role="status">
|
||||
Creating project…
|
||||
</div>
|
||||
) : null}
|
||||
{createError ? (
|
||||
<div className="new-project-modal__status error" role="alert">
|
||||
{createError}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -49,11 +49,20 @@ interface Props {
|
|||
}
|
||||
|
||||
const STORAGE_KEY = 'open-design:workspace-tabs:v1';
|
||||
const OPEN_WORKSPACE_TAB_EVENT = 'open-design:workspace-tabs:open';
|
||||
const MAX_VISIBLE_CHROME_TABS = 16;
|
||||
const MAX_SEARCH_RESULTS = 80;
|
||||
const TAB_STRIP_CONTROL_WIDTH = 112;
|
||||
const MIN_VISIBLE_TAB_WIDTH = 76;
|
||||
|
||||
export function openWorkspaceTab(route: Route): void {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<{ route: Route }>(OPEN_WORKSPACE_TAB_EVENT, {
|
||||
detail: { route },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function nowId(): string {
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
|
@ -292,6 +301,26 @@ export function WorkspaceTabsBar({ route, projects }: Props) {
|
|||
setState((current) => syncStateToRoute(current, route));
|
||||
}, [route]);
|
||||
|
||||
useEffect(() => {
|
||||
function onOpenWorkspaceTab(event: Event) {
|
||||
const detail = (event as CustomEvent<{ route?: Route }>).detail;
|
||||
const nextRoute = detail?.route;
|
||||
if (!nextRoute) return;
|
||||
const nextTab = tabFromRoute(nextRoute);
|
||||
setState((current) => {
|
||||
const normalized = normalizeTabsState(current);
|
||||
return normalizeTabsState({
|
||||
tabs: [...normalized.tabs, nextTab],
|
||||
activeTabId: nextTab.id,
|
||||
});
|
||||
});
|
||||
setTabsMenuOpen(false);
|
||||
}
|
||||
|
||||
window.addEventListener(OPEN_WORKSPACE_TAB_EVENT, onOpenWorkspaceTab);
|
||||
return () => window.removeEventListener(OPEN_WORKSPACE_TAB_EVENT, onOpenWorkspaceTab);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stripElement = stripRef.current;
|
||||
if (!stripElement) return undefined;
|
||||
|
|
|
|||
|
|
@ -66,6 +66,10 @@
|
|||
background: var(--bg-subtle);
|
||||
color: var(--text);
|
||||
}
|
||||
.new-project-modal__close:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.new-project-modal__body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
|
@ -82,3 +86,18 @@
|
|||
min-height: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
.new-project-modal__status {
|
||||
flex: 0 0 auto;
|
||||
margin-top: 10px;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.new-project-modal__status.error {
|
||||
border-color: color-mix(in oklab, var(--danger, #d4543b) 45%, var(--border));
|
||||
background: color-mix(in oklab, var(--danger, #d4543b) 10%, var(--bg-panel));
|
||||
color: var(--danger, #d4543b);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { NewProjectModal } from '../../src/components/NewProjectModal';
|
||||
|
|
@ -80,6 +80,42 @@ describe('NewProjectModal layout', () => {
|
|||
expect(screen.getByTestId('new-project-panel')).toBeTruthy();
|
||||
expect(screen.getByTestId('create-project')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('keeps the modal open with a waiting state until project creation finishes', async () => {
|
||||
let resolveCreate!: (value: boolean) => void;
|
||||
const onCreate = vi.fn(
|
||||
() => new Promise<boolean>((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<NewProjectModal
|
||||
open
|
||||
skills={skills}
|
||||
designSystems={designSystems}
|
||||
defaultDesignSystemId={null}
|
||||
templates={[]}
|
||||
promptTemplates={[]}
|
||||
onCreate={onCreate}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('create-project'));
|
||||
|
||||
expect(onCreate).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole('status').textContent).toContain('Creating project…');
|
||||
expect((screen.getByTestId('create-project') as HTMLButtonElement).disabled).toBe(true);
|
||||
|
||||
resolveCreate(true);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('NewProjectModal template deletion plumbing', () => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { WorkspaceTabsBar } from '../../src/components/WorkspaceTabsBar';
|
||||
import {
|
||||
openWorkspaceTab,
|
||||
WorkspaceTabsBar,
|
||||
} from '../../src/components/WorkspaceTabsBar';
|
||||
import { navigate, type Route } from '../../src/router';
|
||||
import type { Project } from '../../src/types';
|
||||
|
||||
|
|
@ -85,6 +88,19 @@ describe('WorkspaceTabsBar navigation semantics', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('can append and focus a project tab for create-project flows', async () => {
|
||||
render(<WorkspaceTabsBar route={homeRoute} projects={[project]} />);
|
||||
|
||||
openWorkspaceTab(projectRoute);
|
||||
|
||||
await waitFor(() => {
|
||||
const labels = screen.getAllByRole('tab').map((tab) => tab.textContent ?? '');
|
||||
expect(labels).toHaveLength(2);
|
||||
expect(labels.some((label) => label.includes('Home'))).toBe(true);
|
||||
expect(labels.some((label) => label.includes('Project Alpha'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves restored Home tabs instead of collapsing them by route', async () => {
|
||||
window.localStorage.setItem(
|
||||
'open-design:workspace-tabs:v1',
|
||||
|
|
|
|||
Loading…
Reference in a new issue