Thanks to visit codestin.com
Credit goes to github.com

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

+11
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

+14-43
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

-1
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

+50-1
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

+59-28
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

+3-1
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

+14-3
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)