Replace save-as-template prompts with an in-app dialog

This commit is contained in:
shangxinyu1
2026-05-07 13:44:19 +08:00
parent 7d95cbcbe9
commit a173a5e8e3
2 changed files with 163 additions and 18 deletions
+92 -17
View File
@@ -2034,6 +2034,10 @@ function HtmlViewer({
// menu so the user gets feedback without a noisy toast layer.
const [savingTemplate, setSavingTemplate] = useState(false);
const [templateNote, setTemplateNote] = useState<string | null>(null);
const [templateModalOpen, setTemplateModalOpen] = useState(false);
const [templateName, setTemplateName] = useState('');
const [templateDescription, setTemplateDescription] = useState('');
const [templateSaveError, setTemplateSaveError] = useState<string | null>(null);
const [deployment, setDeployment] = useState<DeployProjectFileResponse | null>(null);
const [deployModalOpen, setDeployModalOpen] = useState(false);
const [deployConfig, setDeployConfig] = useState<DeployConfigResponse | null>(null);
@@ -2059,6 +2063,8 @@ function HtmlViewer({
const [manualEditError, setManualEditError] = useState<string | null>(null);
const [manualEditSaving, setManualEditSaving] = useState(false);
const manualEditSavingRef = useRef(false);
const templateNameId = useId();
const templateDescriptionId = useId();
// Opt back into the legacy inline-asset srcDoc path via `?forceInline=1`
// on the host page. Lets users escape-hatch around the URL-load default
// for non-deck HTML that depends on the in-iframe localStorage shim.
@@ -2607,33 +2613,44 @@ function HtmlViewer({
// the viewer), so the template captures the whole design, not a single
// page. Surfaced here in the Share menu because that's where the user's
// share / export mental model already lives.
async function handleSaveAsTemplate() {
function openSaveAsTemplateModal() {
setShareMenuOpen(false);
const defaultName =
file.name.replace(/\.html?$/i, '') || t('fileViewer.templateNameDefault');
const name = window.prompt(t('fileViewer.templateNamePrompt'), defaultName);
if (!name || !name.trim()) return;
const description = window.prompt(
t('fileViewer.templateDescPrompt'),
'',
);
setTemplateName(defaultName);
setTemplateDescription('');
setTemplateSaveError(null);
setTemplateModalOpen(true);
}
async function handleSaveAsTemplate() {
const name = templateName.trim();
if (!name) return;
setSavingTemplate(true);
setTemplateNote(null);
setTemplateSaveError(null);
let savedName: string | null = null;
try {
const tpl = await saveTemplate({
name: name.trim(),
description: description?.trim() || undefined,
name,
description: templateDescription.trim() || undefined,
sourceProjectId: projectId,
});
setTemplateNote(
tpl
? t('fileViewer.savedTemplate', { name: tpl.name })
: t('fileViewer.savedTemplateFail'),
);
if (!tpl) {
setTemplateSaveError(t('fileViewer.savedTemplateFail'));
return;
}
savedName = tpl.name;
setTemplateModalOpen(false);
setTemplateName('');
setTemplateDescription('');
setTemplateNote(t('fileViewer.savedTemplate', { name: tpl.name }));
} finally {
setSavingTemplate(false);
// Auto-clear the note so the menu doesn't keep stale state next open.
setTimeout(() => setTemplateNote(null), 4000);
if (savedName) {
// Auto-clear the note so the menu doesn't keep stale state next open.
setTimeout(() => setTemplateNote(null), 4000);
}
}
}
@@ -3140,7 +3157,7 @@ function HtmlViewer({
role="menuitem"
disabled={savingTemplate}
onClick={() => {
void handleSaveAsTemplate();
openSaveAsTemplateModal();
}}
>
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
@@ -3328,6 +3345,64 @@ function HtmlViewer({
)}
</div>
) : null}
{templateModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal" role="dialog" aria-modal="true">
<div className="modal-head">
<div className="kicker">TEMPLATE</div>
<h2>{t('fileViewer.saveAsTemplate')}</h2>
<p className="subtitle">{t('fileViewer.templateDescPrompt')}</p>
</div>
<div className="deploy-form">
<label className="field" htmlFor={templateNameId}>
<span className="field-label">{t('fileViewer.templateNamePrompt')}</span>
<input
id={templateNameId}
type="text"
value={templateName}
placeholder={t('fileViewer.templateNameDefault')}
autoFocus
onChange={(e) => setTemplateName(e.target.value)}
/>
</label>
<label className="field" htmlFor={templateDescriptionId}>
<span className="field-label">{t('fileViewer.templateDescPrompt')}</span>
<textarea
id={templateDescriptionId}
rows={3}
value={templateDescription}
placeholder={t('fileViewer.optional')}
onChange={(e) => setTemplateDescription(e.target.value)}
/>
</label>
{templateSaveError ? <p className="deploy-error">{templateSaveError}</p> : null}
</div>
<div className="modal-foot">
<button
type="button"
className="ghost-link button-like"
disabled={savingTemplate}
onClick={() => {
setTemplateModalOpen(false);
setTemplateSaveError(null);
}}
>
{t('common.cancel')}
</button>
<button
type="button"
className="viewer-action primary"
disabled={savingTemplate || !templateName.trim()}
onClick={() => {
void handleSaveAsTemplate();
}}
>
{savingTemplate ? t('fileViewer.savingTemplate') : t('common.save')}
</button>
</div>
</div>
</div>
) : null}
{deployModalOpen ? (
<div className="modal-backdrop" role="presentation">
<div className="modal deploy-modal" role="dialog" aria-modal="true">
+71 -1
View File
@@ -1,5 +1,22 @@
// @vitest-environment jsdom
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
const { saveTemplateMock } = vi.hoisted(() => ({
saveTemplateMock: vi.fn(),
}));
vi.mock('../../src/state/projects', async () => {
const actual = await vi.importActual<typeof import('../../src/state/projects')>(
'../../src/state/projects',
);
return {
...actual,
saveTemplate: saveTemplateMock,
};
});
import {
FileViewer,
@@ -189,6 +206,59 @@ describe('FileViewer SVG artifacts', () => {
expect(markup).not.toContain('<![CDATA[');
expect(markup).not.toContain('dangerouslySetInnerHTML');
});
it('uses an in-app modal instead of window.prompt() when saving a template', async () => {
saveTemplateMock.mockResolvedValueOnce({
id: 'tpl_1',
name: 'Landing Page',
description: null,
sourceProjectId: 'project-1',
files: [],
createdAt: Date.now(),
});
const promptSpy = vi.spyOn(window, 'prompt');
const file = baseFile({
name: 'landing-page.html',
path: 'landing-page.html',
mime: 'text/html',
kind: 'html',
artifactManifest: {
version: 1,
kind: 'html',
title: 'Landing Page',
entry: 'landing-page.html',
renderer: 'html',
exports: ['html'],
},
});
render(
<FileViewer
projectId="project-1"
file={file}
liveHtml="<html><body><h1>Hello</h1></body></html>"
/>,
);
fireEvent.click(screen.getByRole('button', { name: /share/i }));
fireEvent.click(screen.getByRole('menuitem', { name: /save as template/i }));
expect(screen.getByRole('dialog')).toBeTruthy();
const nameInput = screen.getByLabelText(/template name/i) as HTMLInputElement;
expect(nameInput.value).toBe('landing-page');
fireEvent.change(nameInput, { target: { value: 'Landing Page' } });
fireEvent.click(screen.getByRole('button', { name: /^save$/i }));
await waitFor(() =>
expect(saveTemplateMock).toHaveBeenCalledWith({
name: 'Landing Page',
description: undefined,
sourceProjectId: 'project-1',
}),
);
expect(promptSpy).not.toHaveBeenCalled();
promptSpy.mockRestore();
});
});
function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact {