Skip to content

Commit 32c36d5

Browse files
authored
feat: allow selecting the initial organization for new users (#16829)
1 parent db064ed commit 32c36d5

File tree

7 files changed

+151
-77
lines changed

7 files changed

+151
-77
lines changed

site/e2e/helpers.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1062,6 +1062,7 @@ type UserValues = {
10621062
export async function createUser(
10631063
page: Page,
10641064
userValues: Partial<UserValues> = {},
1065+
orgName = defaultOrganizationName,
10651066
): Promise<UserValues> {
10661067
const returnTo = page.url();
10671068

@@ -1082,6 +1083,16 @@ export async function createUser(
10821083
await page.getByLabel("Full name").fill(name);
10831084
}
10841085
await page.getByLabel("Email").fill(email);
1086+
1087+
// If the organization picker is present on the page, select the default
1088+
// organization.
1089+
const orgPicker = page.getByLabel("Organization *");
1090+
const organizationsEnabled = await orgPicker.isVisible();
1091+
if (organizationsEnabled) {
1092+
await orgPicker.click();
1093+
await page.getByText(orgName, { exact: true }).click();
1094+
}
1095+
10851096
await page.getByLabel("Login Type").click();
10861097
await page.getByRole("option", { name: "Password", exact: false }).click();
10871098
// Using input[name=password] due to the select element utilizing 'password'

site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx

Lines changed: 14 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,10 @@ import { organizations } from "api/queries/organizations";
77
import type { AuthorizationCheck, Organization } from "api/typesGenerated";
88
import { Avatar } from "components/Avatar/Avatar";
99
import { AvatarData } from "components/Avatar/AvatarData";
10-
import { useDebouncedFunction } from "hooks/debounce";
11-
import {
12-
type ChangeEvent,
13-
type ComponentProps,
14-
type FC,
15-
useState,
16-
} from "react";
10+
import { type ComponentProps, type FC, useState } from "react";
1711
import { useQuery } from "react-query";
1812

1913
export type OrganizationAutocompleteProps = {
20-
value: Organization | null;
2114
onChange: (organization: Organization | null) => void;
2215
label?: string;
2316
className?: string;
@@ -27,21 +20,16 @@ export type OrganizationAutocompleteProps = {
2720
};
2821

2922
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
30-
value,
3123
onChange,
3224
label,
3325
className,
3426
size = "small",
3527
required,
3628
check,
3729
}) => {
38-
const [autoComplete, setAutoComplete] = useState<{
39-
value: string;
40-
open: boolean;
41-
}>({
42-
value: value?.name ?? "",
43-
open: false,
44-
});
30+
const [open, setOpen] = useState(false);
31+
const [selected, setSelected] = useState<Organization | null>(null);
32+
4533
const organizationsQuery = useQuery(organizations());
4634

4735
const permissionsQuery = useQuery(
@@ -60,16 +48,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
6048
: { enabled: false },
6149
);
6250

63-
const { debounced: debouncedInputOnChange } = useDebouncedFunction(
64-
(event: ChangeEvent<HTMLInputElement>) => {
65-
setAutoComplete((state) => ({
66-
...state,
67-
value: event.target.value,
68-
}));
69-
},
70-
750,
71-
);
72-
7351
// If an authorization check was provided, filter the organizations based on
7452
// the results of that check.
7553
let options = organizationsQuery.data ?? [];
@@ -85,24 +63,18 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
8563
className={className}
8664
options={options}
8765
loading={organizationsQuery.isLoading}
88-
value={value}
8966
data-testid="organization-autocomplete"
90-
open={autoComplete.open}
91-
isOptionEqualToValue={(a, b) => a.name === b.name}
67+
open={open}
68+
isOptionEqualToValue={(a, b) => a.id === b.id}
9269
getOptionLabel={(option) => option.display_name}
9370
onOpen={() => {
94-
setAutoComplete((state) => ({
95-
...state,
96-
open: true,
97-
}));
71+
setOpen(true);
9872
}}
9973
onClose={() => {
100-
setAutoComplete({
101-
value: value?.name ?? "",
102-
open: false,
103-
});
74+
setOpen(false);
10475
}}
10576
onChange={(_, newValue) => {
77+
setSelected(newValue);
10678
onChange(newValue);
10779
}}
10880
renderOption={({ key, ...props }, option) => (
@@ -130,13 +102,12 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
130102
}}
131103
InputProps={{
132104
...params.InputProps,
133-
onChange: debouncedInputOnChange,
134-
startAdornment: value && (
135-
<Avatar size="sm" src={value.icon} fallback={value.name} />
105+
startAdornment: selected && (
106+
<Avatar size="sm" src={selected.icon} fallback={selected.name} />
136107
),
137108
endAdornment: (
138109
<>
139-
{organizationsQuery.isFetching && autoComplete.open && (
110+
{organizationsQuery.isFetching && open && (
140111
<CircularProgress size={16} />
141112
)}
142113
{params.InputProps.endAdornment}
@@ -154,6 +125,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
154125
};
155126

156127
const root = css`
157-
padding-left: 14px !important; // Same padding left as input
158-
gap: 4px;
128+
padding-left: 14px !important; // Same padding left as input
129+
gap: 4px;
159130
`;

site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
266266
{...getFieldHelpers("organization")}
267267
required
268268
label="Belongs to"
269-
value={selectedOrg}
270269
onChange={(newValue) => {
271270
setSelectedOrg(newValue);
272271
void form.setFieldValue("organization", newValue?.name || "");

site/src/pages/CreateUserPage/CreateUserForm.stories.tsx

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import { action } from "@storybook/addon-actions";
22
import type { Meta, StoryObj } from "@storybook/react";
3-
import { mockApiError } from "testHelpers/entities";
3+
import { userEvent, within } from "@storybook/test";
4+
import { organizationsKey } from "api/queries/organizations";
5+
import type { Organization } from "api/typesGenerated";
6+
import {
7+
MockOrganization,
8+
MockOrganization2,
9+
mockApiError,
10+
} from "testHelpers/entities";
411
import { CreateUserForm } from "./CreateUserForm";
512

613
const meta: Meta<typeof CreateUserForm> = {
@@ -18,6 +25,48 @@ type Story = StoryObj<typeof CreateUserForm>;
1825

1926
export const Ready: Story = {};
2027

28+
const permissionCheckQuery = (organizations: Organization[]) => {
29+
return {
30+
key: [
31+
"authorization",
32+
{
33+
checks: Object.fromEntries(
34+
organizations.map((org) => [
35+
org.id,
36+
{
37+
action: "create",
38+
object: {
39+
resource_type: "organization_member",
40+
organization_id: org.id,
41+
},
42+
},
43+
]),
44+
),
45+
},
46+
],
47+
data: Object.fromEntries(organizations.map((org) => [org.id, true])),
48+
};
49+
};
50+
51+
export const WithOrganizations: Story = {
52+
parameters: {
53+
queries: [
54+
{
55+
key: organizationsKey,
56+
data: [MockOrganization, MockOrganization2],
57+
},
58+
permissionCheckQuery([MockOrganization, MockOrganization2]),
59+
],
60+
},
61+
args: {
62+
showOrganizations: true,
63+
},
64+
play: async ({ canvasElement }) => {
65+
const canvas = within(canvasElement);
66+
await userEvent.click(canvas.getByLabelText("Organization *"));
67+
},
68+
};
69+
2170
export const FormError: Story = {
2271
args: {
2372
error: mockApiError({

site/src/pages/CreateUserPage/CreateUserForm.tsx

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
77
import { Button } from "components/Button/Button";
88
import { FormFooter } from "components/Form/Form";
99
import { FullPageForm } from "components/FullPageForm/FullPageForm";
10+
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
1011
import { PasswordField } from "components/PasswordField/PasswordField";
1112
import { Spinner } from "components/Spinner/Spinner";
1213
import { Stack } from "components/Stack/Stack";
13-
import { type FormikContextType, useFormik } from "formik";
14+
import { useFormik } from "formik";
1415
import type { FC } from "react";
1516
import {
1617
displayNameValidator,
@@ -52,14 +53,6 @@ export const authMethodLanguage = {
5253
},
5354
};
5455

55-
export interface CreateUserFormProps {
56-
onSubmit: (user: TypesGen.CreateUserRequestWithOrgs) => void;
57-
onCancel: () => void;
58-
error?: unknown;
59-
isLoading: boolean;
60-
authMethods?: TypesGen.AuthMethods;
61-
}
62-
6356
const validationSchema = Yup.object({
6457
email: Yup.string()
6558
.trim()
@@ -75,27 +68,51 @@ const validationSchema = Yup.object({
7568
login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)),
7669
});
7770

71+
type CreateUserFormData = {
72+
readonly username: string;
73+
readonly name: string;
74+
readonly email: string;
75+
readonly organization: string;
76+
readonly login_type: TypesGen.LoginType;
77+
readonly password: string;
78+
};
79+
80+
export interface CreateUserFormProps {
81+
error?: unknown;
82+
isLoading: boolean;
83+
onSubmit: (user: CreateUserFormData) => void;
84+
onCancel: () => void;
85+
authMethods?: TypesGen.AuthMethods;
86+
showOrganizations: boolean;
87+
}
88+
7889
export const CreateUserForm: FC<
7990
React.PropsWithChildren<CreateUserFormProps>
80-
> = ({ onSubmit, onCancel, error, isLoading, authMethods }) => {
81-
const form: FormikContextType<TypesGen.CreateUserRequestWithOrgs> =
82-
useFormik<TypesGen.CreateUserRequestWithOrgs>({
83-
initialValues: {
84-
email: "",
85-
password: "",
86-
username: "",
87-
name: "",
88-
organization_ids: ["00000000-0000-0000-0000-000000000000"],
89-
login_type: "",
90-
user_status: null,
91-
},
92-
validationSchema,
93-
onSubmit,
94-
});
95-
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequestWithOrgs>(
96-
form,
97-
error,
98-
);
91+
> = ({
92+
error,
93+
isLoading,
94+
onSubmit,
95+
onCancel,
96+
showOrganizations,
97+
authMethods,
98+
}) => {
99+
const form = useFormik<CreateUserFormData>({
100+
initialValues: {
101+
email: "",
102+
password: "",
103+
username: "",
104+
name: "",
105+
// If organizations aren't enabled, use the fallback ID to add the user to
106+
// the default organization.
107+
organization: showOrganizations
108+
? ""
109+
: "00000000-0000-0000-0000-000000000000",
110+
login_type: "",
111+
},
112+
validationSchema,
113+
onSubmit,
114+
});
115+
const getFieldHelpers = getFormHelpers(form, error);
99116

100117
const methods = [
101118
authMethods?.password.enabled && "password",
@@ -132,6 +149,20 @@ export const CreateUserForm: FC<
132149
fullWidth
133150
label={Language.emailLabel}
134151
/>
152+
{showOrganizations && (
153+
<OrganizationAutocomplete
154+
{...getFieldHelpers("organization")}
155+
required
156+
label="Organization"
157+
onChange={(newValue) => {
158+
void form.setFieldValue("organization", newValue?.id ?? "");
159+
}}
160+
check={{
161+
object: { resource_type: "organization_member" },
162+
action: "create",
163+
}}
164+
/>
165+
)}
135166
<TextField
136167
{...getFieldHelpers("login_type", {
137168
helperText: "Authentication method for this user",

site/src/pages/CreateUserPage/CreateUserPage.test.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import { Language as FormLanguage } from "./Language";
99

1010
const renderCreateUserPage = async () => {
1111
renderWithAuth(<CreateUserPage />, {
12-
extraRoutes: [{ path: "/users", element: <div>Users Page</div> }],
12+
extraRoutes: [
13+
{ path: "/deployment/users", element: <div>Users Page</div> },
14+
],
1315
});
1416
await waitForLoaderToBeRemoved();
1517
};

site/src/pages/CreateUserPage/CreateUserPage.tsx

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { authMethods, createUser } from "api/queries/users";
22
import { displaySuccess } from "components/GlobalSnackbar/utils";
33
import { Margins } from "components/Margins/Margins";
4+
import { useDashboard } from "modules/dashboard/useDashboard";
45
import type { FC } from "react";
56
import { Helmet } from "react-helmet-async";
67
import { useMutation, useQuery, useQueryClient } from "react-query";
@@ -17,6 +18,7 @@ export const CreateUserPage: FC = () => {
1718
const queryClient = useQueryClient();
1819
const createUserMutation = useMutation(createUser(queryClient));
1920
const authMethodsQuery = useQuery(authMethods());
21+
const { showOrganizations } = useDashboard();
2022

2123
return (
2224
<Margins>
@@ -26,16 +28,25 @@ export const CreateUserPage: FC = () => {
2628

2729
<CreateUserForm
2830
error={createUserMutation.error}
29-
authMethods={authMethodsQuery.data}
31+
isLoading={createUserMutation.isLoading}
3032
onSubmit={async (user) => {
31-
await createUserMutation.mutateAsync(user);
33+
await createUserMutation.mutateAsync({
34+
username: user.username,
35+
name: user.name,
36+
email: user.email,
37+
organization_ids: [user.organization],
38+
login_type: user.login_type,
39+
password: user.password,
40+
user_status: null,
41+
});
3242
displaySuccess("Successfully created user.");
3343
navigate("..", { relative: "path" });
3444
}}
3545
onCancel={() => {
3646
navigate("..", { relative: "path" });
3747
}}
38-
isLoading={createUserMutation.isLoading}
48+
authMethods={authMethodsQuery.data}
49+
showOrganizations={showOrganizations}
3950
/>
4051
</Margins>
4152
);

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy