Skip to content

Commit 2e0941b

Browse files
committed
Extract validation logic to a component
1 parent f33eac2 commit 2e0941b

File tree

12 files changed

+115
-146
lines changed

12 files changed

+115
-146
lines changed

site/src/api/queries/users.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,6 @@ export const updatePassword = () => {
6565
};
6666
};
6767

68-
export const validatePassword = () => {
69-
return {
70-
mutationFn: API.validateUserPassword,
71-
};
72-
};
73-
7468
export const createUser = (queryClient: QueryClient) => {
7569
return {
7670
mutationFn: API.createUser,
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { expect, spyOn, userEvent, waitFor, within } from "@storybook/test";
3+
import { API } from "api/api";
4+
import { useState } from "react";
5+
import { PasswordField } from "./PasswordField";
6+
7+
const meta: Meta<typeof PasswordField> = {
8+
title: "components/PasswordField",
9+
component: PasswordField,
10+
args: {
11+
label: "Password",
12+
},
13+
render: function StatefulPasswordField(args) {
14+
const [value, setValue] = useState("");
15+
return (
16+
<PasswordField
17+
{...args}
18+
value={value}
19+
onChange={(e) => setValue(e.currentTarget.value)}
20+
/>
21+
);
22+
},
23+
};
24+
25+
export default meta;
26+
type Story = StoryObj<typeof PasswordField>;
27+
28+
export const Idle: Story = {};
29+
30+
const securePassword = "s3curePa$$w0rd";
31+
export const Valid: Story = {
32+
play: async ({ canvasElement }) => {
33+
const validatePasswordSpy = spyOn(
34+
API,
35+
"validateUserPassword",
36+
).mockResolvedValueOnce({ valid: true, details: "" });
37+
const user = userEvent.setup();
38+
const canvas = within(canvasElement);
39+
const input = canvas.getByLabelText("Password");
40+
await user.type(input, securePassword);
41+
await waitFor(() =>
42+
expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword),
43+
);
44+
expect(validatePasswordSpy).toHaveBeenCalledTimes(1);
45+
},
46+
};
47+
48+
export const Invalid: Story = {
49+
play: async ({ canvasElement }) => {
50+
const validatePasswordSpy = spyOn(
51+
API,
52+
"validateUserPassword",
53+
).mockResolvedValueOnce({
54+
valid: false,
55+
details: "Password is too short.",
56+
});
57+
const user = userEvent.setup();
58+
const canvas = within(canvasElement);
59+
const input = canvas.getByLabelText("Password");
60+
await user.type(input, securePassword);
61+
await waitFor(() =>
62+
expect(validatePasswordSpy).toHaveBeenCalledWith(securePassword),
63+
);
64+
expect(validatePasswordSpy).toHaveBeenCalledTimes(1);
65+
},
66+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import TextField, { type TextFieldProps } from "@mui/material/TextField";
2+
import { API } from "api/api";
3+
import { useDebouncedValue } from "hooks/debounce";
4+
import type { FC } from "react";
5+
import { useQuery } from "react-query";
6+
7+
// TODO: @BrunoQuaresma: Unable to integrate Yup + Formik for validation. The
8+
// validation was triggering on the onChange event, but the form.errors were not
9+
// updating accordingly. Tried various combinations of validateOnBlur and
10+
// validateOnChange without success. Further investigation is needed.
11+
12+
/**
13+
* A password field component that validates the password against the API with
14+
* debounced calls. It uses a debounced value to minimize the number of API
15+
* calls and displays validation errors.
16+
*/
17+
export const PasswordField: FC<TextFieldProps> = (props) => {
18+
const debouncedValue = useDebouncedValue(`${props.value}`, 500);
19+
const validatePasswordQuery = useQuery({
20+
queryKey: ["validatePassword", debouncedValue],
21+
queryFn: () => API.validateUserPassword(debouncedValue),
22+
keepPreviousData: true,
23+
enabled: debouncedValue.length > 0,
24+
});
25+
const valid = validatePasswordQuery.data?.valid ?? true;
26+
27+
return (
28+
<TextField
29+
{...props}
30+
type="password"
31+
error={!valid || props.error}
32+
helperText={
33+
!valid ? validatePasswordQuery.data?.details : props.helperText
34+
}
35+
/>
36+
);
37+
};

site/src/pages/CreateUserPage/CreateUserForm.tsx

Lines changed: 5 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type * as TypesGen from "api/typesGenerated";
66
import { ErrorAlert } from "components/Alert/ErrorAlert";
77
import { FormFooter } from "components/FormFooter/FormFooter";
88
import { FullPageForm } from "components/FullPageForm/FullPageForm";
9+
import { PasswordField } from "components/PasswordField/PasswordField";
910
import { Stack } from "components/Stack/Stack";
1011
import { type FormikContextType, useFormik } from "formik";
1112
import { type FC, useEffect } from "react";
@@ -63,8 +64,6 @@ export const authMethodLanguage = {
6364
export interface CreateUserFormProps {
6465
onSubmit: (user: TypesGen.CreateUserRequestWithOrgs) => void;
6566
onCancel: () => void;
66-
onPasswordChange: (password: string) => void;
67-
passwordValidator: TypesGen.ValidateUserPasswordResponse;
6867
error?: unknown;
6968
isLoading: boolean;
7069
authMethods?: TypesGen.AuthMethods;
@@ -87,15 +86,7 @@ const validationSchema = Yup.object({
8786

8887
export const CreateUserForm: FC<
8988
React.PropsWithChildren<CreateUserFormProps>
90-
> = ({
91-
onSubmit,
92-
onCancel,
93-
onPasswordChange,
94-
passwordValidator,
95-
error,
96-
isLoading,
97-
authMethods,
98-
}) => {
89+
> = ({ onSubmit, onCancel, error, isLoading, authMethods }) => {
9990
const form: FormikContextType<TypesGen.CreateUserRequestWithOrgs> =
10091
useFormik<TypesGen.CreateUserRequestWithOrgs>({
10192
initialValues: {
@@ -114,10 +105,6 @@ export const CreateUserForm: FC<
114105
error,
115106
);
116107

117-
useEffect(() => {
118-
onPasswordChange?.(form.values.password);
119-
}, [form.values.password, onPasswordChange]); // Run effect when password changes
120-
121108
const methods = [
122109
authMethods?.password.enabled && "password",
123110
authMethods?.oidc.enabled && "oidc",
@@ -200,23 +187,18 @@ export const CreateUserForm: FC<
200187
);
201188
})}
202189
</TextField>
203-
<TextField
190+
<PasswordField
204191
{...getFieldHelpers("password", {
205192
helperText:
206-
(form.values.login_type !== "password" &&
207-
"No password required for this login type") ||
208-
(form.values.password !== "" &&
209-
!passwordValidator.valid &&
210-
passwordValidator.details),
193+
form.values.login_type !== "password" &&
194+
"No password required for this login type",
211195
})}
212196
autoComplete="current-password"
213197
fullWidth
214198
id="password"
215199
data-testid="password-input"
216200
disabled={form.values.login_type !== "password"}
217-
error={!!(form.values.password !== "" && !passwordValidator.valid)}
218201
label={Language.passwordLabel}
219-
type="password"
220202
/>
221203
</Stack>
222204
<FormFooter

site/src/pages/CreateUserPage/CreateUserPage.tsx

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { authMethods, createUser, validatePassword } from "api/queries/users";
1+
import { authMethods, createUser } from "api/queries/users";
22
import { displaySuccess } from "components/GlobalSnackbar/utils";
33
import { Margins } from "components/Margins/Margins";
44
import { useDebouncedFunction } from "hooks/debounce";
@@ -18,25 +18,6 @@ export const CreateUserPage: FC = () => {
1818
const queryClient = useQueryClient();
1919
const createUserMutation = useMutation(createUser(queryClient));
2020
const authMethodsQuery = useQuery(authMethods());
21-
const validatePasswordMutation = useMutation(validatePassword());
22-
23-
const [passwordValidator, setPasswordValidator] = useState({
24-
valid: false,
25-
details: "",
26-
});
27-
28-
const validateUserPassword = async (password: string) => {
29-
validatePasswordMutation.mutate(password, {
30-
onSuccess: (data) => {
31-
setPasswordValidator({ valid: data.valid, details: data.details });
32-
},
33-
});
34-
};
35-
36-
const { debounced: debouncedValidateUserPassword } = useDebouncedFunction(
37-
validateUserPassword,
38-
500,
39-
);
4021

4122
return (
4223
<Margins>
@@ -55,8 +36,6 @@ export const CreateUserPage: FC = () => {
5536
onCancel={() => {
5637
navigate("..", { relative: "path" });
5738
}}
58-
onPasswordChange={debouncedValidateUserPassword}
59-
passwordValidator={passwordValidator}
6039
isLoading={createUserMutation.isLoading}
6140
/>
6241
</Margins>

site/src/pages/SetupPage/SetupPage.tsx

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import { buildInfo } from "api/queries/buildInfo";
2-
import { validatePassword } from "api/queries/users";
32
import { createFirstUser } from "api/queries/users";
43
import { Loader } from "components/Loader/Loader";
54
import { useAuthContext } from "contexts/auth/AuthProvider";
6-
import { useDebouncedFunction } from "hooks/debounce";
75
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
86
import { type FC, useEffect, useState } from "react";
97
import { Helmet } from "react-helmet-async";
@@ -22,18 +20,11 @@ export const SetupPage: FC = () => {
2220
isSigningIn,
2321
} = useAuthContext();
2422
const createFirstUserMutation = useMutation(createFirstUser());
25-
const validatePasswordMutation = useMutation(validatePassword());
26-
2723
const setupIsComplete = !isConfiguringTheFirstUser;
2824
const { metadata } = useEmbeddedMetadata();
2925
const buildInfoQuery = useQuery(buildInfo(metadata["build-info"]));
3026
const navigate = useNavigate();
3127

32-
const [passwordValidator, setPasswordValidator] = useState({
33-
valid: false,
34-
details: "",
35-
});
36-
3728
useEffect(() => {
3829
if (!buildInfoQuery.data) {
3930
return;
@@ -43,19 +34,6 @@ export const SetupPage: FC = () => {
4334
});
4435
}, [buildInfoQuery.data]);
4536

46-
const validateUserPassword = async (password: string) => {
47-
validatePasswordMutation.mutate(password, {
48-
onSuccess: (data) => {
49-
setPasswordValidator({ valid: data.valid, details: data.details });
50-
},
51-
});
52-
};
53-
54-
const { debounced: debouncedValidateUserPassword } = useDebouncedFunction(
55-
validateUserPassword,
56-
500,
57-
);
58-
5937
if (isLoading) {
6038
return <Loader fullscreen />;
6139
}
@@ -76,8 +54,6 @@ export const SetupPage: FC = () => {
7654
<title>{pageTitle("Set up your account")}</title>
7755
</Helmet>
7856
<SetupPageView
79-
onPasswordChange={debouncedValidateUserPassword}
80-
passwordValidator={passwordValidator}
8157
isLoading={isSigningIn || createFirstUserMutation.isLoading}
8258
error={createFirstUserMutation.error}
8359
onSubmit={async (firstUser) => {

site/src/pages/SetupPage/SetupPageView.stories.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,6 @@ export const TrialError: Story = {
3131
},
3232
};
3333

34-
export const PasswordValidation: Story = {
35-
args: {
36-
passwordValidator: { valid: false, details: "Password is too short" },
37-
},
38-
};
39-
4034
export const Loading: Story = {
4135
args: {
4236
isLoading: true,

site/src/pages/SetupPage/SetupPageView.tsx

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { isAxiosError } from "axios";
1010
import { Alert, AlertDetail } from "components/Alert/Alert";
1111
import { FormFields, VerticalForm } from "components/Form/Form";
1212
import { CoderIcon } from "components/Icons/CoderIcon";
13+
import { PasswordField } from "components/PasswordField/PasswordField";
1314
import { SignInLayout } from "components/SignInLayout/SignInLayout";
1415
import { Stack } from "components/Stack/Stack";
1516
import { type FormikContextType, useFormik } from "formik";
@@ -34,7 +35,6 @@ export const Language = {
3435
passwordRequired: "Please enter a password.",
3536
create: "Create account",
3637
welcomeMessage: <>Welcome to Coder</>,
37-
3838
firstNameLabel: "First name",
3939
lastNameLabel: "Last name",
4040
companyLabel: "Company",
@@ -83,16 +83,12 @@ const numberOfDevelopersOptions = [
8383

8484
export interface SetupPageViewProps {
8585
onSubmit: (firstUser: TypesGen.CreateFirstUserRequest) => void;
86-
onPasswordChange?: (password: string) => void;
87-
passwordValidator: TypesGen.ValidateUserPasswordResponse;
8886
error?: unknown;
8987
isLoading?: boolean;
9088
}
9189

9290
export const SetupPageView: FC<SetupPageViewProps> = ({
9391
onSubmit,
94-
onPasswordChange,
95-
passwordValidator,
9692
error,
9793
isLoading,
9894
}) => {
@@ -122,10 +118,6 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
122118
error,
123119
);
124120

125-
useEffect(() => {
126-
onPasswordChange?.(form.values.password);
127-
}, [form.values.password, onPasswordChange]); // Run effect when password changes
128-
129121
return (
130122
<SignInLayout>
131123
<header css={{ textAlign: "center", marginBottom: 32 }}>
@@ -176,17 +168,11 @@ export const SetupPageView: FC<SetupPageViewProps> = ({
176168
fullWidth
177169
label={Language.emailLabel}
178170
/>
179-
<TextField
171+
<PasswordField
180172
{...getFieldHelpers("password")}
181173
autoComplete="current-password"
182174
fullWidth
183-
id="password"
184175
label={Language.passwordLabel}
185-
type="password"
186-
error={!!(form.values.password !== "" && !passwordValidator.valid)}
187-
helperText={
188-
!passwordValidator.valid ? passwordValidator.details : ""
189-
}
190176
/>
191177
<label
192178
htmlFor="trial"

site/src/pages/UserSettingsPage/SecurityPage/SecurityForm.stories.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,12 @@ type Story = StoryObj<typeof SecurityForm>;
1616
export const Example: Story = {
1717
args: {
1818
isLoading: false,
19-
onPasswordChange: (password: string) => {},
2019
},
2120
};
2221

2322
export const Loading: Story = {
2423
args: {
2524
isLoading: true,
26-
onPasswordChange: (password: string) => {},
2725
},
2826
};
2927

@@ -38,6 +36,5 @@ export const WithError: Story = {
3836
},
3937
],
4038
}),
41-
onPasswordChange: (password: string) => {},
4239
},
4340
};

0 commit comments

Comments
 (0)