Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI – Updates to confirm invite flow #25583

Merged
merged 13 commits into from
Jan 24, 2025
1 change: 1 addition & 0 deletions changes/24486-error-for-invalid-invites
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Check the server for validity of any Fleet invites

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,14 @@ import ConfirmInviteForm from "components/forms/ConfirmInviteForm";

describe("ConfirmInviteForm - component", () => {
const handleSubmitSpy = jest.fn();
const inviteToken = "abc123";
const formData = { invite_token: inviteToken };
const defaultFormData = { name: "Test User" };

it("renders", () => {
render(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);
expect(
screen.getByRole("textbox", { name: "Full name" })
Expand All @@ -26,30 +28,28 @@ describe("ConfirmInviteForm - component", () => {
const baseError = "Unable to authenticate the current user";
render(
<ConfirmInviteForm
serverErrors={{ base: baseError }}
ancestorError={baseError}
handleSubmit={handleSubmitSpy}
/>
);

expect(screen.getByText(baseError)).toBeInTheDocument();
});

it("calls the handleSubmit prop with the invite_token when valid", async () => {
it("calls the handleSubmit prop when valid", async () => {
const { user } = renderWithSetup(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);

await user.type(
screen.getByRole("textbox", { name: "Full name" }),
"Gnar Dog"
);
await user.type(screen.getByLabelText("Password"), "p@ssw0rd");
await user.type(screen.getByLabelText("Confirm password"), "p@ssw0rd");
await user.click(screen.getByRole("button", { name: "Submit" }));

expect(handleSubmitSpy).toHaveBeenCalledWith({
...formData,
name: "Gnar Dog",
...defaultFormData,
password: "p@ssw0rd",
password_confirmation: "p@ssw0rd",
});
Expand All @@ -58,7 +58,10 @@ describe("ConfirmInviteForm - component", () => {
describe("name input", () => {
it("validates the field must be present", async () => {
const { user } = renderWithSetup(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={{ ...defaultFormData, ...{ name: "" } }}
handleSubmit={handleSubmitSpy}
/>
);

await user.click(screen.getByRole("button", { name: "Submit" }));
Expand All @@ -72,7 +75,10 @@ describe("ConfirmInviteForm - component", () => {
describe("password input", () => {
it("validates the field must be present", async () => {
const { user } = renderWithSetup(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);

await user.click(screen.getByRole("button", { name: "Submit" }));
Expand All @@ -86,7 +92,10 @@ describe("ConfirmInviteForm - component", () => {
describe("password_confirmation input", () => {
it("validates the password_confirmation matches the password", async () => {
const { user } = renderWithSetup(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);

await user.type(screen.getByLabelText("Password"), "p@ssw0rd");
Expand All @@ -104,7 +113,10 @@ describe("ConfirmInviteForm - component", () => {

it("validates the field must be present", async () => {
const { user } = renderWithSetup(
<ConfirmInviteForm formData={formData} handleSubmit={handleSubmitSpy} />
<ConfirmInviteForm
defaultFormData={defaultFormData}
handleSubmit={handleSubmitSpy}
/>
);

await user.click(screen.getByRole("button", { name: "Submit" }));
Expand Down
149 changes: 149 additions & 0 deletions frontend/components/forms/ConfirmInviteForm/ConfirmInviteForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import React, { useCallback, useState } from "react";

import validateEquality from "components/forms/validators/validate_equality";

import Button from "components/buttons/Button";
// @ts-ignore
import InputField from "components/forms/fields/InputField";
import { IFormField } from "interfaces/form_field";

const baseClass = "confirm-invite-page__form";
export interface IConfirmInviteFormData {
name: string;
password: string;
password_confirmation: string;
}
interface IConfirmInviteFormProps {
defaultFormData?: Partial<IConfirmInviteFormData>;
handleSubmit: (data: IConfirmInviteFormData) => void;
ancestorError?: string;
}
interface IConfirmInviteFormErrors {
name?: string | null;
password?: string | null;
password_confirmation?: string | null;
}

const validate = (formData: IConfirmInviteFormData) => {
const errors: IConfirmInviteFormErrors = {};
const {
name,
password,
password_confirmation: passwordConfirmation,
} = formData;

if (!name) {
errors.name = "Full name must be present";
}

if (
password &&
passwordConfirmation &&
!validateEquality(password, passwordConfirmation)
) {
errors.password_confirmation =
"Password confirmation does not match password";
}

if (!password) {
errors.password = "Password must be present";
}

if (!passwordConfirmation) {
errors.password_confirmation = "Password confirmation must be present";
}

return errors;
};
const ConfirmInviteForm = ({
defaultFormData,
handleSubmit,
ancestorError,
}: IConfirmInviteFormProps) => {
const [formData, setFormData] = useState<IConfirmInviteFormData>({
name: defaultFormData?.name || "",
password: defaultFormData?.password || "",
password_confirmation: defaultFormData?.password || "",
});
const [formErrors, setFormErrors] = useState<IConfirmInviteFormErrors>({});

const { name, password, password_confirmation } = formData;

const onInputChange = ({ name: n, value }: IFormField) => {
const newFormData = { ...formData, [n]: value };
setFormData(newFormData);
const newErrs = validate(newFormData);
// only set errors that are updates of existing errors
// new errors are only set on submit
const errsToSet: Record<string, string> = {};
Object.keys(formErrors).forEach((k) => {
// @ts-ignore
if (newErrs[k]) {
// @ts-ignore
errsToSet[k] = newErrs[k];
}
});
setFormErrors(errsToSet);
};

const onSubmit = useCallback(
(evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault();

const errs = validate(formData);
if (Object.keys(errs).length > 0) {
setFormErrors(errs);
return;
}
handleSubmit(formData);
},
[formData, handleSubmit]
);

return (
<form onSubmit={onSubmit} className={baseClass} autoComplete="off">
{ancestorError && <div className="form__base-error">{ancestorError}</div>}
<InputField
label="Full name"
autofocus
onChange={onInputChange}
name="name"
value={name}
error={formErrors.name}
parseTarget
maxLength={80}
/>
<InputField
label="Password"
type="password"
placeholder="Password"
helpText="Must include 12 characters, at least 1 number (e.g. 0 - 9), and at least 1 symbol (e.g. &*#)"
onChange={onInputChange}
name="password"
value={password}
error={formErrors.password}
parseTarget
/>
<InputField
label="Confirm password"
type="password"
placeholder="Confirm password"
onChange={onInputChange}
name="password_confirmation"
value={password_confirmation}
error={formErrors.password_confirmation}
parseTarget
/>
<Button
type="submit"
disabled={Object.keys(formErrors).length > 0}
className="confirm-invite-button"
variant="brand"
>
Submit
</Button>
</form>
);
};

export default ConfirmInviteForm;
38 changes: 38 additions & 0 deletions frontend/components/forms/ConfirmInviteForm/helpers.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { size } from "lodash";
import validateEquality from "components/forms/validators/validate_equality";
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This just calls lodash's isEqual. Using here for consistency but perhaps we can retire this function in favor of calling the lodash method directly.


const validate = (formData) => {
const errors = {};
const {
name,
password,
password_confirmation: passwordConfirmation,
} = formData;

if (!name) {
errors.name = "Full name must be present";
}

if (
password &&
passwordConfirmation &&
!validateEquality(password, passwordConfirmation)
) {
errors.password_confirmation =
"Password confirmation does not match password";
}

if (!password) {
errors.password = "Password must be present";
}

if (!passwordConfirmation) {
errors.password_confirmation = "Password confirmation must be present";
}

const valid = !size(errors);

return { valid, errors };
};

export default { validate };
11 changes: 11 additions & 0 deletions frontend/docs/patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,17 @@ export default PackComposerPage;

## Forms

### Form submission

When building a React-controlled form:
- Use the native HTML `form` element to wrap the form.
- Use a `Button` component with `type="submit"` for its submit button.
- Write a submit handler, e.g. `handleSubmit`, that accepts an `evt:
React.FormEvent<HTMLFormElement>` argument and, critically, calls `evt.preventDefault()` in its
body. This prevents the HTML `form`'s default submit behavior from interfering with our custom
handler's logic.
- Assign that handler to the `form`'s `onSubmit` property (*not* the submit button's `onClick`)

### Data validation

#### How to validate
Expand Down
Loading
Loading