Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/components/Shared/SubmitButton/SubmitButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import React from 'react';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { SubmitButton } from './SubmitButton';

describe('SubmitButton', () => {
it('calls onSubmit when clicked', async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] **Test gives false confidence — none of the documented contract is covered.** It asserts only `onSubmit` was called once after one click; it never issues a second click and never inspects `disabled`, so it passes whether or not the guard works. Baseline confirmed this suite is green against the broken implementation. Add a double-click-while-pending regression test (see the suggested test in the review summary) so the guard can't silently break again.

const onSubmit = jest.fn().mockResolvedValue(undefined);
const { getByRole } = render(
<SubmitButton onSubmit={onSubmit}>Submit</SubmitButton>,
);

await userEvent.click(getByRole('button', { name: 'Submit' }));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] **`await userEvent.click(...)` violates the repo checklist** ("Never `await` `userEvent` calls"). Drop the `await`. Beyond style, awaiting the click flushes the microtask queue before any assertion, which is part of why the in-flight disabled state couldn't be observed — making the guard test easy to omit.


expect(onSubmit).toHaveBeenCalledTimes(1);
});
});
35 changes: 35 additions & 0 deletions src/components/Shared/SubmitButton/SubmitButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useState } from 'react';
import { Button } from '@mui/material';

interface SubmitButtonProps {
/**
* Async submit handler. The button must not invoke this more than once
* per in-flight submission (guards against duplicate requests).
*/
onSubmit: () => Promise<void>;
children: React.ReactNode;
}

/**
* A submit button that guards against double-submission: while `onSubmit`
* is in flight the button disables itself, so a second click cannot fire a
* duplicate request (e.g. creating two donations from one intent).
*/
export const SubmitButton: React.FC<SubmitButtonProps> = ({

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] **Naming collision / duplication.** `SubmitButton` is already exported by `Shared/Modal/ActionButtons/ActionButtons.tsx` and imported by ~55 files app-wide (plus a local one in `HrTools/SalaryCalculator/StepNavigation`). A third `SubmitButton` sitting directly under `Shared/` is the most discoverable location and will be confused with the adopted one on auto-import. Rename (e.g. `AsyncSubmitButton`) or fold the async-guard behavior into the existing component — cheap now with 0 importers, expensive later.

onSubmit,
children,
}) => {
const [isSubmitting, setIsSubmitting] = useState(false);

const handleClick = async () => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] **Double-submit guard is dead — `setIsSubmitting(true)` is never called.** ✅ REPRODUCED by a scratch test during `verify`.

isSubmitting starts false and the only state write is setIsSubmitting(false) on line 27, so disabled={isSubmitting} (line 31) is always false. The button never disables while onSubmit is in flight — a user can double-click and fire two concurrent submits, the exact "two donations from one intent" bug the doc comment promises to prevent.

Evidence: rendering with a pending onSubmit and asserting expect(button).toBeDisabled() fails — the rendered <button> has no disabled attribute mid-flight. The existing test is green, so nothing catches this.

Fix (also resolves the missing try/finally below):

const handleClick = async () => {
  setIsSubmitting(true);
  try {
    await onSubmit();
  } finally {
    setIsSubmitting(false);
  }
};

Flagged by all 4 agents (Architecture 9, Testing 9, UX 9, Standards 8).

// Runs the submit handler and re-enables the button when it settles.
await onSubmit();

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Important] **No `try/finally` — the error path bricks the button.** `await onSubmit()` isn't wrapped. Today a rejection is an unhandled promise rejection; once the guard above is fixed *without* a `finally`, a failed submit (network/GraphQL error) leaves `isSubmitting === true` forever — button stuck disabled, no retry. The `finally` in the Critical fix above resolves this. Per repo UX rules, do **not** add a manual snackbar — the global Apollo error link already toasts GraphQL/network errors.

setIsSubmitting(false);
};

return (
<Button onClick={handleClick} disabled={isSubmitting} variant="contained">

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Named `SubmitButton` and semantically submits, but renders a default `type="button"` wired to `onClick` — it won't respond to Enter inside a form and bypasses Formik's `handleSubmit`. Either commit to `type="submit"` semantics (and align with Formik's `isSubmitting`) or rename to reflect it's a generic async action button.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dismissed by author: Will add the loading indicator separately] [Medium] No loading indicator during submission. Even once the disabled state works, a greyed-out button is a subtle cue with no spinner; on a slow request users may perceive it as unresponsive. MUI v7 `` gives spinner + disable + `aria-busy` in one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dismissed by author: Minor UX, not necessary] [Suggestion] No `aria-busy` / live-region signal during submission, so screen-reader users get no announcement that a submit is in progress. Resolved for free by the MUI `loading` prop.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Dismissed by author: Will be implemented later] [Suggestion] `variant="contained"` is hardcoded and no `ButtonProps` (color, `type`, `sx`, `data-testid`) are forwarded, limiting reuse for a `Shared/` component. Consider spreading `...rest` of `ButtonProps` with `variant` defaulting to `contained`.

{children}
</Button>
);
};
Loading