mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* 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
262 lines
7.7 KiB
TypeScript
262 lines
7.7 KiB
TypeScript
// @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();
|
|
});
|
|
});
|