mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(web): sketch save button shows saving and saved feedback (#2500)
* fix(web): ensure sketch save button shows saving state
The save handler completes so quickly that the saving UI state flashes
for less than a frame, making it impossible for users to perceive that
a save is actually in progress. Add a 500ms minimum delay on the save
path so the button's saving state is visible, giving users clear
feedback that their action was registered.
Fixes #109
* fix(web): show saved confirmation after sketch save completes
After a sketch save succeeds, show a checkmark icon on the save button
for 2 seconds so users get clear confirmation that the save completed,
addressing the missing success feedback reported in the issue.
Fixes #109
* test(web): add save feedback tests for SketchEditor
Cover the Save button's default, saving, disabled, and post-save
checkmark states, including the 2-second saved confirmation timeout.
Refs #109
* fix(web): gate saved checkmark on explicit save success
saveSketch now returns false on write failure (daemon down, 4xx/5xx,
disk error, dropped connection) instead of resolving normally.
handleSave checks the return value and skips the green checkmark when
the save did not persist.
Refs #109
* fix(web): make sketch save delay a minimum floor not additive
saveSketch previously added a hardcoded 500ms after writeProjectTextFile completed, making the total perceived save time writeTime + 500ms regardless of how long the real write took.
Now it measures elapsed write time and only sleeps for the remainder of 500ms — fast saves still get the minimum visible duration, but a save that takes 600ms skips the sleep entirely.
Refs #109
* fix(web): remove showSaved from sketch save button disable guard
saveSketch's handleSave already skips the save when onSave returns false, so showSaved was doubling as an accidental debounce that blocked the user from saving again for 2 seconds after each save — even when they had continued drawing during the save animation.
The saving prop from the parent and the dirty/canSave check remain in place, so the button is still disabled while the async write is in flight and when there is nothing new to persist.
Refs #109
* fix(web): clear savedTimerRef on unmount
savedTimerRef schedules a setTimeout that calls setShowSaved(false)
after the save confirmation period. When the component unmounts before
the timer fires (e.g., closing the sketch tab), the pending callback
triggers a state update on an unmounted component. Add a useEffect
cleanup to clear the timer, consistent with the existing ResizeObserver
cleanup in this component.
Refs #109
* fix(web): strip trailing whitespace on save button className line
The className="primary" attribute on the save button accumulated
trailing whitespace in the recent save feedback changes. Remove it.
Refs #109
* test(web): add sketch save minimum visibility test for FileWorkspace
Add a test that verifies the sketch save button keeps the "Saving…"
state visible for at least 500ms before transitioning to the saved
checkmark. Mocks fetchProjectFileText, writeProjectTextFile, and a
stub ResizeObserver to make the SketchEditor render cleanly.
Refs #109
* test(web): update SketchEditor save disable assertion after removing showSaved guard
a6aa82a removed showSaved from the save button disable guard, so the button no longer stays disabled for 2 seconds after each save completes. This test expected the save button to remain disabled after save resolution, which no longer matches the component behavior — the saving prop (false) and dirty/canSave check (true) leave the button enabled immediately after save.
Refs #109
* fix(web): clear saved indicator when sketch save fails
When a save succeeds (shows checkmark), then the user saves again
and the second save fails, the stale checkmark remained visible
until the original 2-second timeout expired. The handler now clears
the timer and hides the indicator immediately on failure.
Refs #109
* fix(web): clear saved checkmark when sketch goes dirty again
After a successful save, the checkmark remained visible for the full
2-second window even if the user resumed drawing during that time.
dirty would become true again while the button still showed "Saved",
contradicting the actual unsaved state.
A new useEffect watches the dirty prop and immediately clears the
timer and hides the indicator when the sketch becomes dirty again.
Refs #109
* refactor(web): fix useEffect dependency array indentation
The closing bracket of the useEffect dependency array was indented
one space too far, breaking the code style consistency in
SketchEditor.tsx.
Refs #109
* refactor(web): remove trailing blank line in FileWorkspace
A blank line with trailing whitespace was left between the useEffect
closing brace and the following if-statement, violating the file's
whitespace conventions.
Refs #109
* fix(web): give sketch save button a stable accessible name
Refs #109
* test(web): add a11y tests for sketch save button aria-label
Refs #109
This commit is contained in:
@@ -724,7 +724,11 @@ export function FileWorkspace({
|
||||
entry.discardRawItemsOnSave ? [] : entry.rawItems,
|
||||
entry.items,
|
||||
);
|
||||
const startedAt = Date.now();
|
||||
const file = await writeProjectTextFile(projectId, name, JSON.stringify(doc, null, 2));
|
||||
const elapsed = Date.now() - startedAt;
|
||||
// Ensures saving UI shows so the button does not flicker
|
||||
if (elapsed < 500) await new Promise((resolve) => setTimeout(resolve, 500 - elapsed));
|
||||
if (file) {
|
||||
setSketches((curr) => ({
|
||||
...curr,
|
||||
@@ -745,8 +749,10 @@ export function FileWorkspace({
|
||||
});
|
||||
setActiveTab(name);
|
||||
await onRefreshFiles();
|
||||
return true;
|
||||
} else {
|
||||
setSketches((curr) => ({ ...curr, [name]: { ...curr[name]!, saving: false } }));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useT } from '../i18n';
|
||||
import { Icon } from './Icon';
|
||||
import { readDefaultSketchToolColor } from './sketch-colors';
|
||||
import type { SketchItem } from './sketch-model';
|
||||
|
||||
const SAVED_VISIBLE_MS = 2000;
|
||||
|
||||
export type Tool = 'select' | 'pen' | 'text' | 'rect' | 'arrow' | 'eraser';
|
||||
|
||||
interface Props {
|
||||
@@ -13,7 +16,7 @@ interface Props {
|
||||
hasPreservedRawItems?: boolean;
|
||||
onItemsChange: (items: SketchItem[]) => void;
|
||||
onClear?: () => void;
|
||||
onSave: () => Promise<void> | void;
|
||||
onSave: () => Promise<boolean | void> | boolean | void;
|
||||
onCancel?: () => void;
|
||||
saving?: boolean;
|
||||
dirty?: boolean;
|
||||
@@ -39,6 +42,20 @@ export function SketchEditor({
|
||||
const [size, setSize] = useState(2);
|
||||
const drawingRef = useRef<SketchItem | null>(null);
|
||||
const [, force] = useState(0);
|
||||
const [showSaved, setShowSaved] = useState(false);
|
||||
const savedTimerRef = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
useEffect(() => {
|
||||
return () => clearTimeout(savedTimerRef.current);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dirty) {
|
||||
clearTimeout(savedTimerRef.current);
|
||||
setShowSaved(false);
|
||||
}
|
||||
}, [dirty]);
|
||||
|
||||
// Text-tool modal. Replaces window.prompt() because Electron 28+
|
||||
// disables that API by default and silently returns null, making
|
||||
// the text tool a no-op in the desktop app. Same root cause as
|
||||
@@ -189,6 +206,18 @@ export function SketchEditor({
|
||||
textAnchorRef.current = null;
|
||||
}
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const ok = await onSave();
|
||||
if (ok === false) {
|
||||
clearTimeout(savedTimerRef.current);
|
||||
setShowSaved(false);
|
||||
return;
|
||||
}
|
||||
setShowSaved(true);
|
||||
clearTimeout(savedTimerRef.current);
|
||||
savedTimerRef.current = setTimeout(() => setShowSaved(false), SAVED_VISIBLE_MS);
|
||||
}, [onSave]);
|
||||
|
||||
return (
|
||||
<div className="sketch-editor">
|
||||
<div className="sketch-toolbar">
|
||||
@@ -234,10 +263,11 @@ export function SketchEditor({
|
||||
) : null}
|
||||
<button
|
||||
className="primary"
|
||||
onClick={() => void onSave()}
|
||||
onClick={handleSave}
|
||||
disabled={saving || !canSave}
|
||||
aria-label={saving ? t('sketch.saving') : showSaved ? t('sketch.saved') : t('common.save')}
|
||||
>
|
||||
{saving ? t('sketch.saving') : t('common.save')}
|
||||
{saving ? t('sketch.saving') : showSaved ? <Icon name="check" size={14} /> : t('common.save')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="sketch-canvas-wrap" ref={wrapRef}>
|
||||
|
||||
@@ -929,6 +929,7 @@ export const ar: Dict = {
|
||||
'sketch.save': 'حفظ الرسم',
|
||||
'sketch.cancel': 'إلغاء',
|
||||
'sketch.saving': 'جاري الحفظ...',
|
||||
'sketch.saved': 'تم الحفظ',
|
||||
'sketch.tooltipDirty': 'تغييرات غير محفوظة',
|
||||
'sketch.tooltipClean': 'تم الحفظ',
|
||||
'fileViewer.empty': 'اختر ملفاً لعرضه.',
|
||||
|
||||
@@ -817,6 +817,7 @@ export const de: Dict = {
|
||||
'sketch.save': 'Sketch speichern',
|
||||
'sketch.cancel': 'Abbrechen',
|
||||
'sketch.saving': 'Speichern…',
|
||||
'sketch.saved': 'Gespeichert',
|
||||
'sketch.tooltipDirty': 'Ungespeicherte Änderungen',
|
||||
'sketch.tooltipClean': 'Gespeichert',
|
||||
'fileViewer.empty': 'Wählen Sie eine Datei zur Ansicht aus.',
|
||||
|
||||
@@ -1520,6 +1520,7 @@ export const en: Dict = {
|
||||
'sketch.save': 'Save sketch',
|
||||
'sketch.cancel': 'Cancel',
|
||||
'sketch.saving': 'Saving…',
|
||||
'sketch.saved': 'Saved',
|
||||
'sketch.tooltipDirty': 'Unsaved changes',
|
||||
'sketch.tooltipClean': 'Saved',
|
||||
'fileViewer.empty': 'Select a file to view.',
|
||||
|
||||
@@ -818,6 +818,7 @@ export const esES: Dict = {
|
||||
'sketch.save': 'Guardar boceto',
|
||||
'sketch.cancel': 'Cancelar',
|
||||
'sketch.saving': 'Guardando…',
|
||||
'sketch.saved': 'Guardado',
|
||||
'sketch.tooltipDirty': 'Cambios sin guardar',
|
||||
'sketch.tooltipClean': 'Guardado',
|
||||
'fileViewer.empty': 'Selecciona un archivo para verlo.',
|
||||
|
||||
@@ -953,6 +953,7 @@ export const fa: Dict = {
|
||||
'sketch.save': 'ذخیره طرح',
|
||||
'sketch.cancel': 'لغو',
|
||||
'sketch.saving': 'در حال ذخیره…',
|
||||
'sketch.saved': 'ذخیره شد',
|
||||
'sketch.tooltipDirty': 'تغییرات ذخیره نشده',
|
||||
'sketch.tooltipClean': 'ذخیره شد',
|
||||
'fileViewer.empty': 'یک فایل را برای مشاهده انتخاب کنید.',
|
||||
|
||||
@@ -945,6 +945,7 @@ export const fr: Dict = {
|
||||
'sketch.save': 'Enregistrer le croquis',
|
||||
'sketch.cancel': 'Annuler',
|
||||
'sketch.saving': 'Enregistrement…',
|
||||
'sketch.saved': 'Enregistré',
|
||||
'sketch.tooltipDirty': 'Modifications non enregistrées',
|
||||
'sketch.tooltipClean': 'Enregistré',
|
||||
'fileViewer.empty': 'Sélectionnez un fichier à afficher.',
|
||||
|
||||
@@ -929,6 +929,7 @@ export const hu: Dict = {
|
||||
'sketch.save': 'Vázlat mentése',
|
||||
'sketch.cancel': 'Mégse',
|
||||
'sketch.saving': 'Mentés…',
|
||||
'sketch.saved': 'Mentve',
|
||||
'sketch.tooltipDirty': 'Mentetlen változtatások',
|
||||
'sketch.tooltipClean': 'Mentve',
|
||||
'fileViewer.empty': 'Válassz fájlt a megtekintéshez.',
|
||||
|
||||
@@ -1045,6 +1045,7 @@ export const id: Dict = {
|
||||
'sketch.save': 'Simpan',
|
||||
'sketch.cancel': 'Batal',
|
||||
'sketch.saving': 'Menyimpan...',
|
||||
'sketch.saved': 'Tersimpan',
|
||||
'sketch.tooltipDirty': 'Ada perubahan belum disimpan',
|
||||
'sketch.tooltipClean': 'Tersimpan',
|
||||
|
||||
|
||||
@@ -844,6 +844,7 @@ export const it: Dict = {
|
||||
'sketch.save': 'Salva schizzo',
|
||||
'sketch.cancel': 'Annulla',
|
||||
'sketch.saving': 'Salvataggio…',
|
||||
'sketch.saved': 'Salvato',
|
||||
'sketch.tooltipDirty': 'Modifiche non salvate',
|
||||
'sketch.tooltipClean': 'Salvato',
|
||||
'fileViewer.empty': 'Seleziona un file da visualizzare.',
|
||||
|
||||
@@ -816,6 +816,7 @@ export const ja: Dict = {
|
||||
'sketch.save': 'スケッチを保存',
|
||||
'sketch.cancel': 'キャンセル',
|
||||
'sketch.saving': '保存中…',
|
||||
'sketch.saved': '保存済み',
|
||||
'sketch.tooltipDirty': '未保存の変更があります',
|
||||
'sketch.tooltipClean': '保存済み',
|
||||
'fileViewer.empty': 'ファイルを選択して表示します。',
|
||||
|
||||
@@ -929,6 +929,7 @@ export const ko: Dict = {
|
||||
'sketch.save': '스케치 저장',
|
||||
'sketch.cancel': '취소',
|
||||
'sketch.saving': '저장 중…',
|
||||
'sketch.saved': '저장됨',
|
||||
'sketch.tooltipDirty': '저장되지 않은 변경 사항',
|
||||
'sketch.tooltipClean': '저장됨',
|
||||
'fileViewer.empty': '보려는 파일을 선택하세요.',
|
||||
|
||||
@@ -929,6 +929,7 @@ export const pl: Dict = {
|
||||
'sketch.save': 'Zapisz szkic',
|
||||
'sketch.cancel': 'Anuluj',
|
||||
'sketch.saving': 'Zapisywanie…',
|
||||
'sketch.saved': 'Zapisano',
|
||||
'sketch.tooltipDirty': 'Niezapisane zmiany',
|
||||
'sketch.tooltipClean': 'Zapisano',
|
||||
'fileViewer.empty': 'Wybierz plik, aby go wyświetlić.',
|
||||
|
||||
@@ -952,6 +952,7 @@ export const ptBR: Dict = {
|
||||
'sketch.save': 'Salvar esboço',
|
||||
'sketch.cancel': 'Cancelar',
|
||||
'sketch.saving': 'Salvando…',
|
||||
'sketch.saved': 'Salvo',
|
||||
'sketch.tooltipDirty': 'Alterações não salvas',
|
||||
'sketch.tooltipClean': 'Salvo',
|
||||
'fileViewer.empty': 'Selecione um arquivo para visualizar.',
|
||||
|
||||
@@ -952,6 +952,7 @@ export const ru: Dict = {
|
||||
'sketch.save': 'Сохранить эскиз',
|
||||
'sketch.cancel': 'Отмена',
|
||||
'sketch.saving': 'Сохранение…',
|
||||
'sketch.saved': 'Сохранено',
|
||||
'sketch.tooltipDirty': 'Несохраненные изменения',
|
||||
'sketch.tooltipClean': 'Сохранено',
|
||||
'fileViewer.empty': 'Выберите файл для просмотра.',
|
||||
|
||||
@@ -870,6 +870,7 @@ export const th: Dict = {
|
||||
'sketch.save': 'บันทึกรูป',
|
||||
'sketch.cancel': 'ยกเลิก',
|
||||
'sketch.saving': 'กำลังบันทึกรูปภาพ…',
|
||||
'sketch.saved': 'บันทึกแล้ว',
|
||||
'sketch.tooltipDirty': 'มีการเปลี่ยนผ่าน',
|
||||
'sketch.tooltipClean': 'บันทึกสมบูรณ์',
|
||||
'fileViewer.empty': 'คลิกที่ไฟล์เพื่อแสดงดู',
|
||||
|
||||
@@ -916,6 +916,7 @@ export const tr: Dict = {
|
||||
'sketch.save': 'Taslağı kaydet',
|
||||
'sketch.cancel': 'İptal et',
|
||||
'sketch.saving': 'Kaydediliyor…',
|
||||
'sketch.saved': 'Kaydedildi',
|
||||
'sketch.tooltipDirty': 'Kaydedilmemiş değişiklikler',
|
||||
'sketch.tooltipClean': 'Kaydedildi',
|
||||
'fileViewer.empty': 'Görüntülemek için bir dosya seçin.',
|
||||
|
||||
@@ -971,6 +971,7 @@ export const uk: Dict = {
|
||||
'sketch.save': 'Зберегти ескіз',
|
||||
'sketch.cancel': 'Скасувати',
|
||||
'sketch.saving': 'Збереження…',
|
||||
'sketch.saved': 'Збережено',
|
||||
'sketch.tooltipDirty': 'Незбережені зміни',
|
||||
'sketch.tooltipClean': 'Збережено',
|
||||
'fileViewer.empty': 'Виберіть файл для перегляду.',
|
||||
|
||||
@@ -1510,6 +1510,7 @@ export const zhCN: Dict = {
|
||||
'sketch.save': '保存草图',
|
||||
'sketch.cancel': '取消',
|
||||
'sketch.saving': '保存中…',
|
||||
'sketch.saved': '已保存',
|
||||
'sketch.tooltipDirty': '尚未保存',
|
||||
'sketch.tooltipClean': '已保存',
|
||||
'fileViewer.empty': '请选择一个文件查看。',
|
||||
|
||||
@@ -1121,6 +1121,7 @@ export const zhTW: Dict = {
|
||||
'sketch.save': '儲存草圖',
|
||||
'sketch.cancel': '取消',
|
||||
'sketch.saving': '儲存中…',
|
||||
'sketch.saved': '已儲存',
|
||||
'sketch.tooltipDirty': '尚未儲存',
|
||||
'sketch.tooltipClean': '已儲存',
|
||||
'fileViewer.empty': '請選擇一個檔案檢視。',
|
||||
|
||||
@@ -1836,6 +1836,7 @@ export interface Dict {
|
||||
'sketch.save': string;
|
||||
'sketch.cancel': string;
|
||||
'sketch.saving': string;
|
||||
'sketch.saved': string;
|
||||
'sketch.tooltipDirty': string;
|
||||
'sketch.tooltipClean': string;
|
||||
'fileViewer.empty': string;
|
||||
|
||||
@@ -4,12 +4,16 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { act } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { FileWorkspace, scrollWorkspaceTabsWithWheel } from '../../src/components/FileWorkspace';
|
||||
import { DesignFilesPanel } from '../../src/components/DesignFilesPanel';
|
||||
import { projectSplitClassName } from '../../src/components/ProjectView';
|
||||
import { uploadProjectFiles } from '../../src/providers/registry';
|
||||
import {
|
||||
fetchProjectFileText,
|
||||
uploadProjectFiles,
|
||||
writeProjectTextFile,
|
||||
} from '../../src/providers/registry';
|
||||
import type { ProjectFile } from '../../src/types';
|
||||
|
||||
vi.mock('../../src/providers/registry', async () => {
|
||||
@@ -18,17 +22,30 @@ vi.mock('../../src/providers/registry', async () => {
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
fetchProjectFileText: vi.fn(),
|
||||
uploadProjectFiles: vi.fn(),
|
||||
writeProjectTextFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
const mockedFetchProjectFileText = vi.mocked(fetchProjectFileText);
|
||||
const mockedUploadProjectFiles = vi.mocked(uploadProjectFiles);
|
||||
const mockedWriteProjectTextFile = vi.mocked(writeProjectTextFile);
|
||||
|
||||
let root: Root | null = null;
|
||||
let host: HTMLDivElement | null = null;
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// Needed else the ResizeObserver in SketchEditor crashes the test
|
||||
beforeAll(() => {
|
||||
globalThis.ResizeObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
if (root) {
|
||||
@@ -678,3 +695,71 @@ describe('scrollWorkspaceTabsWithWheel', () => {
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FileWorkspace sketch save', () => {
|
||||
it('keeps saving state visible for at least 500ms', async () => {
|
||||
// Simulate user doing some edits in the workspace
|
||||
const file: ProjectFile = {
|
||||
name: 'test.sketch.json',
|
||||
path: 'test.sketch.json',
|
||||
type: 'file',
|
||||
size: 100,
|
||||
mtime: 1700000000,
|
||||
kind: 'sketch',
|
||||
mime: 'application/json',
|
||||
};
|
||||
|
||||
mockedFetchProjectFileText.mockResolvedValue(
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
items: [
|
||||
{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
mockedWriteProjectTextFile.mockResolvedValue(file);
|
||||
|
||||
render(
|
||||
<FileWorkspace
|
||||
projectId="project-1"
|
||||
projectKind="prototype"
|
||||
files={[file]}
|
||||
liveArtifacts={[]}
|
||||
onRefreshFiles={vi.fn()}
|
||||
isDeck={false}
|
||||
tabsState={{ tabs: ['test.sketch.json'], active: 'test.sketch.json' }}
|
||||
onTabsStateChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('canvas')).not.toBeNull();
|
||||
});
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
const btn = screen.getByText('Save') as HTMLButtonElement;
|
||||
expect(btn.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(btn);
|
||||
});
|
||||
|
||||
expect(btn.textContent).toBe('Saving…');
|
||||
expect(btn.disabled).toBe(true);
|
||||
|
||||
// Before the 500ms floor is reached, still saving
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400);
|
||||
});
|
||||
expect(btn.textContent).toBe('Saving…');
|
||||
expect(btn.disabled).toBe(true);
|
||||
|
||||
// After 500ms total, saving should end and the checkmark should appear
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(100);
|
||||
});
|
||||
expect(btn.textContent).not.toBe('Saving…');
|
||||
expect(btn.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, cleanup, fireEvent, render } from '@testing-library/react';
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SketchEditor } from '../../src/components/SketchEditor';
|
||||
|
||||
vi.mock('../../src/i18n', () => ({
|
||||
useT: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
beforeAll(() => {
|
||||
vi.stubGlobal('ResizeObserver', class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
unobserve() {}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
function renderEditor(overrides: Partial<Parameters<typeof SketchEditor>[0]> = {}) {
|
||||
return render(
|
||||
<SketchEditor
|
||||
items={[]}
|
||||
onItemsChange={noop}
|
||||
onSave={noop}
|
||||
fileName="test.sketch.json"
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
function saveButton(): HTMLButtonElement {
|
||||
return document.querySelector('button.primary') as HTMLButtonElement;
|
||||
}
|
||||
|
||||
describe('SketchEditor save', () => {
|
||||
it('shows the Save label by default', () => {
|
||||
renderEditor({ dirty: true });
|
||||
expect(saveButton().textContent).toBe('common.save');
|
||||
});
|
||||
|
||||
it('shows the saving label when saving', () => {
|
||||
renderEditor({ saving: true, dirty: true });
|
||||
expect(saveButton().textContent).toBe('sketch.saving');
|
||||
});
|
||||
|
||||
it('disables the button while saving', () => {
|
||||
renderEditor({ saving: true, dirty: true });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('disables the button when nothing is editable', () => {
|
||||
renderEditor({ items: [], dirty: false, hasPreservedRawItems: false });
|
||||
expect(saveButton().disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('enables the button when there are items', () => {
|
||||
renderEditor({
|
||||
items: [{ kind: 'pen', points: [{ x: 10, y: 20 }], color: '#000', size: 2 }],
|
||||
});
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the button when dirty', () => {
|
||||
renderEditor({ dirty: true });
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('enables the button when there are preserved raw items', () => {
|
||||
renderEditor({ hasPreservedRawItems: true });
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('calls onSave when clicked', () => {
|
||||
const onSave = vi.fn();
|
||||
renderEditor({ dirty: true, onSave });
|
||||
fireEvent.click(saveButton());
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the checkmark icon after save completes', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
const btn = saveButton();
|
||||
expect(btn.textContent).not.toBe('common.save');
|
||||
expect(btn.querySelector('svg')).not.toBeNull();
|
||||
expect(btn.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('reverts to the Save label after the saved indicator expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn().mockResolvedValue(undefined);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
|
||||
expect(saveButton().textContent).not.toBe('common.save');
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(saveButton().textContent).toBe('common.save');
|
||||
expect(saveButton().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not show the checkmark when save fails', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(false);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(saveButton().textContent).toBe('common.save');
|
||||
expect(saveButton().querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the checkmark when dirty becomes true after a successful save', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const { rerender } = renderEditor({ dirty: true, onSave });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(saveButton().querySelector('svg')).not.toBeNull();
|
||||
|
||||
// Parent updates dirty=false after successful save, then dirty=true when user draws again
|
||||
rerender(
|
||||
<SketchEditor
|
||||
items={[]}
|
||||
onItemsChange={noop}
|
||||
onSave={onSave}
|
||||
fileName="test.sketch.json"
|
||||
dirty={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<SketchEditor
|
||||
items={[]}
|
||||
onItemsChange={noop}
|
||||
onSave={onSave}
|
||||
fileName="test.sketch.json"
|
||||
dirty={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(saveButton().textContent).toBe('common.save');
|
||||
expect(saveButton().querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('hides the checkmark when save fails if success indicator is still visible', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn()
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(saveButton().textContent).not.toBe('common.save');
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(saveButton().textContent).toBe('common.save');
|
||||
expect(saveButton().querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('has an aria-label matching the default save state', () => {
|
||||
renderEditor();
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
|
||||
});
|
||||
|
||||
it('has an aria-label when dirty is true', () => {
|
||||
renderEditor({ dirty: true });
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
|
||||
});
|
||||
|
||||
it('has an aria-label showing saving state while saving', () => {
|
||||
renderEditor({ saving: true, dirty: true });
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('sketch.saving');
|
||||
});
|
||||
|
||||
it('has an aria-label showing saved state after successful save', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
const btn = saveButton();
|
||||
expect(btn.getAttribute('aria-label')).toBe('sketch.saved');
|
||||
expect(btn.querySelector('svg')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('reverts the aria-label to default after saved indicator expires', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('sketch.saved');
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(2000);
|
||||
});
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
|
||||
});
|
||||
|
||||
it('keeps the aria-label as default when save fails', async () => {
|
||||
const onSave = vi.fn().mockResolvedValue(false);
|
||||
renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
|
||||
});
|
||||
|
||||
it('shows the default aria-label when dirty becomes true after a successful save', async () => {
|
||||
vi.useFakeTimers();
|
||||
const onSave = vi.fn().mockResolvedValue(true);
|
||||
const { rerender } = renderEditor({ dirty: true, onSave });
|
||||
await act(async () => {
|
||||
fireEvent.click(saveButton());
|
||||
});
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('sketch.saved');
|
||||
rerender(
|
||||
<SketchEditor
|
||||
items={[]}
|
||||
onItemsChange={noop}
|
||||
onSave={onSave}
|
||||
fileName="test.sketch.json"
|
||||
dirty={false}
|
||||
/>,
|
||||
);
|
||||
rerender(
|
||||
<SketchEditor
|
||||
items={[]}
|
||||
onItemsChange={noop}
|
||||
onSave={onSave}
|
||||
fileName="test.sketch.json"
|
||||
dirty={true}
|
||||
/>,
|
||||
);
|
||||
expect(saveButton().getAttribute('aria-label')).toBe('common.save');
|
||||
expect(saveButton().querySelector('svg')).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user