Skip to content

Commit 9b3f87e

Browse files
Improve handling of save new, overwrite, wipe all
1 parent 47c6a1a commit 9b3f87e

3 files changed

Lines changed: 112 additions & 10 deletions

File tree

src/__tests__/components/modals/SaveAsProjectModal.test.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,4 +200,57 @@ describe('SaveAsProjectModal', () => {
200200
await waitFor(() => expect(screen.queryByText('Unnamed')).not.toBeInTheDocument());
201201
expect(screen.getByText('French glosses')).toBeInTheDocument();
202202
});
203+
204+
it('disables the save-as-new button while a save is in flight to block duplicate submits', async () => {
205+
let resolveSave: () => void = () => {};
206+
const onSaveNew = jest.fn(
207+
() =>
208+
new Promise<void>((resolve) => {
209+
resolveSave = resolve;
210+
}),
211+
);
212+
render(<SaveAsProjectModal {...defaultProps} onSaveNew={onSaveNew} />);
213+
214+
// Let the mount's project-list load settle before interacting, so its state update is flushed.
215+
await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel' })).not.toBeDisabled());
216+
217+
const saveButton = screen.getByTestId('save-as-new');
218+
await userEvent.click(saveButton);
219+
220+
// While the save promise is pending the button is disabled, so the user cannot submit again.
221+
expect(saveButton).toBeDisabled();
222+
expect(onSaveNew).toHaveBeenCalledTimes(1);
223+
224+
// Resolving the save re-enables the button (this unit test keeps the modal mounted).
225+
resolveSave();
226+
await waitFor(() => expect(saveButton).not.toBeDisabled());
227+
});
228+
229+
it('ignores an error from a project-list load that a newer load has superseded', async () => {
230+
let rejectFirst: (reason: unknown) => void = () => {};
231+
mockSendCommand
232+
.mockImplementationOnce(
233+
() =>
234+
new Promise((_resolve, reject) => {
235+
rejectFirst = reject;
236+
}),
237+
)
238+
.mockResolvedValue(JSON.stringify([STUB_PROJECT_2]));
239+
240+
const { rerender } = render(<SaveAsProjectModal {...defaultProps} />);
241+
242+
// Supersede the first (still-pending) load by changing the source before it settles.
243+
rerender(<SaveAsProjectModal {...defaultProps} sourceProjectId="src-proj-2" />);
244+
await waitFor(() => expect(screen.getByText('French glosses')).toBeInTheDocument());
245+
246+
// Fail the stale first load: because it has been superseded, it must not log or notify.
247+
rejectFirst(new Error('stale failure'));
248+
await new Promise((resolve) => {
249+
setTimeout(resolve, 0);
250+
});
251+
252+
expect(logger.error).not.toHaveBeenCalled();
253+
expect(papi.notifications.send).not.toHaveBeenCalled();
254+
expect(screen.getByText('French glosses')).toBeInTheDocument();
255+
});
203256
});

src/components/InterlinearizerLoader.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -276,9 +276,10 @@ function InterlinearizerLoaderInner({
276276
if (wipeConfirm === 'book') {
277277
/* v8 ignore next -- wipe-book is only offered once a book is loaded */
278278
if (book) wipeBook(book.bookRef);
279-
} else {
280-
wipeAll();
281279
}
280+
// Match 'all' explicitly rather than via an else, so a future wipe scope can't fall through to
281+
// the destructive whole-draft wipe.
282+
if (wipeConfirm === 'all') wipeAll();
282283
setWipeConfirm(undefined);
283284
}, [wipeConfirm, book, wipeBook, wipeAll]);
284285

src/components/modals/SaveAsProjectModal.tsx

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,8 @@ export function SaveAsProjectModal({
5454
sourceProjectId: string;
5555
defaultName?: string;
5656
defaultDescription?: string;
57-
onSaveNew: (name?: string, description?: string) => void;
58-
onOverwrite: (project: InterlinearProjectSummary) => void;
57+
onSaveNew: (name?: string, description?: string) => void | Promise<void>;
58+
onOverwrite: (project: InterlinearProjectSummary) => void | Promise<void>;
5959
onClose: () => void;
6060
}>) {
6161
const [localizedStrings, stringsLoading] = useLocalizedStrings(SAVE_AS_MODAL_STRING_KEYS);
@@ -65,6 +65,15 @@ export function SaveAsProjectModal({
6565
const [projects, setProjects] = useState<InterlinearProjectSummary[]>([]);
6666
const [isLoading, setIsLoading] = useState(true);
6767

68+
/**
69+
* True while a save (new or overwrite) is in flight; disables the save controls to block
70+
* re-submits.
71+
*/
72+
const [isSubmitting, setIsSubmitting] = useState(false);
73+
// Ref mirror of `isSubmitting` so a submit handler can short-circuit a second invocation
74+
// synchronously, before the re-render that disables the button lands (guards programmatic races).
75+
const isSubmittingRef = useRef(false);
76+
6877
/** The existing project pending an overwrite confirmation, or `undefined`. */
6978
const [confirmOverwrite, setConfirmOverwrite] = useState<InterlinearProjectSummary | undefined>(
7079
undefined,
@@ -96,6 +105,9 @@ export function SaveAsProjectModal({
96105
throw new TypeError('getProjectsForSource did not return an array');
97106
setProjects(parsed.filter(isInterlinearProjectSummary));
98107
} catch (e) {
108+
// Ignore a failure from a load that a newer one has superseded (mirrors the success-path stale
109+
// guard above) so a stale rejection cannot fire a spurious error notification.
110+
if (gen !== loadGenRef.current) return;
99111
logger.error('Interlinearizer: failed to load projects for Save As', e);
100112
await papi.notifications
101113
.send({ message: '%interlinearizer_error_load_projects_failed%', severity: 'error' })
@@ -109,11 +121,46 @@ export function SaveAsProjectModal({
109121
loadProjects();
110122
}, [loadProjects]);
111123

112-
/** Saves the draft as a new project with the trimmed name/description (blank fields → undefined). */
113-
const handleSaveNew = useCallback(() => {
114-
onSaveNew(name.trim() || undefined, description.trim() || undefined);
124+
/**
125+
* Saves the draft as a new project with the trimmed name/description (blank fields → undefined),
126+
* blocking re-entry while the save is in flight so a double-click cannot create duplicate
127+
* projects.
128+
*/
129+
const handleSaveNew = useCallback(async () => {
130+
/* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */
131+
if (isSubmittingRef.current) return;
132+
isSubmittingRef.current = true;
133+
setIsSubmitting(true);
134+
try {
135+
await onSaveNew(name.trim() || undefined, description.trim() || undefined);
136+
} finally {
137+
isSubmittingRef.current = false;
138+
setIsSubmitting(false);
139+
}
115140
}, [name, description, onSaveNew]);
116141

142+
/**
143+
* Overwrites the chosen existing project with the draft, blocking re-entry while the save is in
144+
* flight so a double-click cannot fire the overwrite (or another save) twice.
145+
*
146+
* @param project - The existing project to overwrite.
147+
*/
148+
const handleConfirmOverwrite = useCallback(
149+
async (project: InterlinearProjectSummary) => {
150+
/* v8 ignore next -- button is disabled while submitting; ref guards against programmatic races */
151+
if (isSubmittingRef.current) return;
152+
isSubmittingRef.current = true;
153+
setIsSubmitting(true);
154+
try {
155+
await onOverwrite(project);
156+
} finally {
157+
isSubmittingRef.current = false;
158+
setIsSubmitting(false);
159+
}
160+
},
161+
[onOverwrite],
162+
);
163+
117164
/* v8 ignore next */ if (stringsLoading) return undefined;
118165

119166
return (
@@ -153,7 +200,7 @@ export function SaveAsProjectModal({
153200
placeholder={localizedStrings['%interlinearizer_modal_create_description_placeholder%']}
154201
/>
155202
<div className="tw:flex tw:justify-end tw:mb-4">
156-
<Button onClick={handleSaveNew} data-testid="save-as-new">
203+
<Button onClick={handleSaveNew} data-testid="save-as-new" disabled={isSubmitting}>
157204
{localizedStrings['%interlinearizer_modal_saveAs_save_new%']}
158205
</Button>
159206
</div>
@@ -199,7 +246,8 @@ export function SaveAsProjectModal({
199246
variant="destructive"
200247
size="sm"
201248
data-testid="save-as-overwrite-confirm"
202-
onClick={() => onOverwrite(confirmOverwrite)}
249+
onClick={() => handleConfirmOverwrite(confirmOverwrite)}
250+
disabled={isSubmitting}
203251
>
204252
{localizedStrings['%interlinearizer_modal_saveAs_overwrite_confirm_ok%']}
205253
</Button>
@@ -208,7 +256,7 @@ export function SaveAsProjectModal({
208256
)}
209257

210258
<div className="tw:flex tw:gap-2 tw:justify-end">
211-
<Button variant="secondary" onClick={onClose} disabled={isLoading}>
259+
<Button variant="secondary" onClick={onClose} disabled={isLoading || isSubmitting}>
212260
{localizedStrings['%interlinearizer_modal_saveAs_cancel%']}
213261
</Button>
214262
</div>

0 commit comments

Comments
 (0)