feat: allow selecting the initial organization for new users (#16829)

This commit is contained in:
ケイラ
2025-03-07 08:42:10 -07:00
committed by GitHub
parent db064ed0f8
commit 32c36d5336
7 changed files with 151 additions and 77 deletions

View File

@ -1062,6 +1062,7 @@ type UserValues = {
export async function createUser( export async function createUser(
page: Page, page: Page,
userValues: Partial<UserValues> = {}, userValues: Partial<UserValues> = {},
orgName = defaultOrganizationName,
): Promise<UserValues> { ): Promise<UserValues> {
const returnTo = page.url(); const returnTo = page.url();
@ -1082,6 +1083,16 @@ export async function createUser(
await page.getByLabel("Full name").fill(name); await page.getByLabel("Full name").fill(name);
} }
await page.getByLabel("Email").fill(email); await page.getByLabel("Email").fill(email);
// If the organization picker is present on the page, select the default
// organization.
const orgPicker = page.getByLabel("Organization *");
const organizationsEnabled = await orgPicker.isVisible();
if (organizationsEnabled) {
await orgPicker.click();
await page.getByText(orgName, { exact: true }).click();
}
await page.getByLabel("Login Type").click(); await page.getByLabel("Login Type").click();
await page.getByRole("option", { name: "Password", exact: false }).click(); await page.getByRole("option", { name: "Password", exact: false }).click();
// Using input[name=password] due to the select element utilizing 'password' // Using input[name=password] due to the select element utilizing 'password'

View File

@ -7,17 +7,10 @@ import { organizations } from "api/queries/organizations";
import type { AuthorizationCheck, Organization } from "api/typesGenerated"; import type { AuthorizationCheck, Organization } from "api/typesGenerated";
import { Avatar } from "components/Avatar/Avatar"; import { Avatar } from "components/Avatar/Avatar";
import { AvatarData } from "components/Avatar/AvatarData"; import { AvatarData } from "components/Avatar/AvatarData";
import { useDebouncedFunction } from "hooks/debounce"; import { type ComponentProps, type FC, useState } from "react";
import {
type ChangeEvent,
type ComponentProps,
type FC,
useState,
} from "react";
import { useQuery } from "react-query"; import { useQuery } from "react-query";
export type OrganizationAutocompleteProps = { export type OrganizationAutocompleteProps = {
value: Organization | null;
onChange: (organization: Organization | null) => void; onChange: (organization: Organization | null) => void;
label?: string; label?: string;
className?: string; className?: string;
@ -27,7 +20,6 @@ export type OrganizationAutocompleteProps = {
}; };
export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
value,
onChange, onChange,
label, label,
className, className,
@ -35,13 +27,9 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
required, required,
check, check,
}) => { }) => {
const [autoComplete, setAutoComplete] = useState<{ const [open, setOpen] = useState(false);
value: string; const [selected, setSelected] = useState<Organization | null>(null);
open: boolean;
}>({
value: value?.name ?? "",
open: false,
});
const organizationsQuery = useQuery(organizations()); const organizationsQuery = useQuery(organizations());
const permissionsQuery = useQuery( const permissionsQuery = useQuery(
@ -60,16 +48,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
: { enabled: false }, : { enabled: false },
); );
const { debounced: debouncedInputOnChange } = useDebouncedFunction(
(event: ChangeEvent<HTMLInputElement>) => {
setAutoComplete((state) => ({
...state,
value: event.target.value,
}));
},
750,
);
// If an authorization check was provided, filter the organizations based on // If an authorization check was provided, filter the organizations based on
// the results of that check. // the results of that check.
let options = organizationsQuery.data ?? []; let options = organizationsQuery.data ?? [];
@ -85,24 +63,18 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
className={className} className={className}
options={options} options={options}
loading={organizationsQuery.isLoading} loading={organizationsQuery.isLoading}
value={value}
data-testid="organization-autocomplete" data-testid="organization-autocomplete"
open={autoComplete.open} open={open}
isOptionEqualToValue={(a, b) => a.name === b.name} isOptionEqualToValue={(a, b) => a.id === b.id}
getOptionLabel={(option) => option.display_name} getOptionLabel={(option) => option.display_name}
onOpen={() => { onOpen={() => {
setAutoComplete((state) => ({ setOpen(true);
...state,
open: true,
}));
}} }}
onClose={() => { onClose={() => {
setAutoComplete({ setOpen(false);
value: value?.name ?? "",
open: false,
});
}} }}
onChange={(_, newValue) => { onChange={(_, newValue) => {
setSelected(newValue);
onChange(newValue); onChange(newValue);
}} }}
renderOption={({ key, ...props }, option) => ( renderOption={({ key, ...props }, option) => (
@ -130,13 +102,12 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
}} }}
InputProps={{ InputProps={{
...params.InputProps, ...params.InputProps,
onChange: debouncedInputOnChange, startAdornment: selected && (
startAdornment: value && ( <Avatar size="sm" src={selected.icon} fallback={selected.name} />
<Avatar size="sm" src={value.icon} fallback={value.name} />
), ),
endAdornment: ( endAdornment: (
<> <>
{organizationsQuery.isFetching && autoComplete.open && ( {organizationsQuery.isFetching && open && (
<CircularProgress size={16} /> <CircularProgress size={16} />
)} )}
{params.InputProps.endAdornment} {params.InputProps.endAdornment}
@ -154,6 +125,6 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
}; };
const root = css` const root = css`
padding-left: 14px !important; // Same padding left as input padding-left: 14px !important; // Same padding left as input
gap: 4px; gap: 4px;
`; `;

View File

@ -266,7 +266,6 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
{...getFieldHelpers("organization")} {...getFieldHelpers("organization")}
required required
label="Belongs to" label="Belongs to"
value={selectedOrg}
onChange={(newValue) => { onChange={(newValue) => {
setSelectedOrg(newValue); setSelectedOrg(newValue);
void form.setFieldValue("organization", newValue?.name || ""); void form.setFieldValue("organization", newValue?.name || "");

View File

@ -1,6 +1,13 @@
import { action } from "@storybook/addon-actions"; import { action } from "@storybook/addon-actions";
import type { Meta, StoryObj } from "@storybook/react"; import type { Meta, StoryObj } from "@storybook/react";
import { mockApiError } from "testHelpers/entities"; import { userEvent, within } from "@storybook/test";
import { organizationsKey } from "api/queries/organizations";
import type { Organization } from "api/typesGenerated";
import {
MockOrganization,
MockOrganization2,
mockApiError,
} from "testHelpers/entities";
import { CreateUserForm } from "./CreateUserForm"; import { CreateUserForm } from "./CreateUserForm";
const meta: Meta<typeof CreateUserForm> = { const meta: Meta<typeof CreateUserForm> = {
@ -18,6 +25,48 @@ type Story = StoryObj<typeof CreateUserForm>;
export const Ready: Story = {}; export const Ready: Story = {};
const permissionCheckQuery = (organizations: Organization[]) => {
return {
key: [
"authorization",
{
checks: Object.fromEntries(
organizations.map((org) => [
org.id,
{
action: "create",
object: {
resource_type: "organization_member",
organization_id: org.id,
},
},
]),
),
},
],
data: Object.fromEntries(organizations.map((org) => [org.id, true])),
};
};
export const WithOrganizations: Story = {
parameters: {
queries: [
{
key: organizationsKey,
data: [MockOrganization, MockOrganization2],
},
permissionCheckQuery([MockOrganization, MockOrganization2]),
],
},
args: {
showOrganizations: true,
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByLabelText("Organization *"));
},
};
export const FormError: Story = { export const FormError: Story = {
args: { args: {
error: mockApiError({ error: mockApiError({

View File

@ -7,10 +7,11 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Button } from "components/Button/Button"; import { Button } from "components/Button/Button";
import { FormFooter } from "components/Form/Form"; import { FormFooter } from "components/Form/Form";
import { FullPageForm } from "components/FullPageForm/FullPageForm"; import { FullPageForm } from "components/FullPageForm/FullPageForm";
import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete";
import { PasswordField } from "components/PasswordField/PasswordField"; import { PasswordField } from "components/PasswordField/PasswordField";
import { Spinner } from "components/Spinner/Spinner"; import { Spinner } from "components/Spinner/Spinner";
import { Stack } from "components/Stack/Stack"; import { Stack } from "components/Stack/Stack";
import { type FormikContextType, useFormik } from "formik"; import { useFormik } from "formik";
import type { FC } from "react"; import type { FC } from "react";
import { import {
displayNameValidator, displayNameValidator,
@ -52,14 +53,6 @@ export const authMethodLanguage = {
}, },
}; };
export interface CreateUserFormProps {
onSubmit: (user: TypesGen.CreateUserRequestWithOrgs) => void;
onCancel: () => void;
error?: unknown;
isLoading: boolean;
authMethods?: TypesGen.AuthMethods;
}
const validationSchema = Yup.object({ const validationSchema = Yup.object({
email: Yup.string() email: Yup.string()
.trim() .trim()
@ -75,27 +68,51 @@ const validationSchema = Yup.object({
login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)), login_type: Yup.string().oneOf(Object.keys(authMethodLanguage)),
}); });
type CreateUserFormData = {
readonly username: string;
readonly name: string;
readonly email: string;
readonly organization: string;
readonly login_type: TypesGen.LoginType;
readonly password: string;
};
export interface CreateUserFormProps {
error?: unknown;
isLoading: boolean;
onSubmit: (user: CreateUserFormData) => void;
onCancel: () => void;
authMethods?: TypesGen.AuthMethods;
showOrganizations: boolean;
}
export const CreateUserForm: FC< export const CreateUserForm: FC<
React.PropsWithChildren<CreateUserFormProps> React.PropsWithChildren<CreateUserFormProps>
> = ({ onSubmit, onCancel, error, isLoading, authMethods }) => { > = ({
const form: FormikContextType<TypesGen.CreateUserRequestWithOrgs> = error,
useFormik<TypesGen.CreateUserRequestWithOrgs>({ isLoading,
initialValues: { onSubmit,
email: "", onCancel,
password: "", showOrganizations,
username: "", authMethods,
name: "", }) => {
organization_ids: ["00000000-0000-0000-0000-000000000000"], const form = useFormik<CreateUserFormData>({
login_type: "", initialValues: {
user_status: null, email: "",
}, password: "",
validationSchema, username: "",
onSubmit, name: "",
}); // If organizations aren't enabled, use the fallback ID to add the user to
const getFieldHelpers = getFormHelpers<TypesGen.CreateUserRequestWithOrgs>( // the default organization.
form, organization: showOrganizations
error, ? ""
); : "00000000-0000-0000-0000-000000000000",
login_type: "",
},
validationSchema,
onSubmit,
});
const getFieldHelpers = getFormHelpers(form, error);
const methods = [ const methods = [
authMethods?.password.enabled && "password", authMethods?.password.enabled && "password",
@ -132,6 +149,20 @@ export const CreateUserForm: FC<
fullWidth fullWidth
label={Language.emailLabel} label={Language.emailLabel}
/> />
{showOrganizations && (
<OrganizationAutocomplete
{...getFieldHelpers("organization")}
required
label="Organization"
onChange={(newValue) => {
void form.setFieldValue("organization", newValue?.id ?? "");
}}
check={{
object: { resource_type: "organization_member" },
action: "create",
}}
/>
)}
<TextField <TextField
{...getFieldHelpers("login_type", { {...getFieldHelpers("login_type", {
helperText: "Authentication method for this user", helperText: "Authentication method for this user",

View File

@ -9,7 +9,9 @@ import { Language as FormLanguage } from "./Language";
const renderCreateUserPage = async () => { const renderCreateUserPage = async () => {
renderWithAuth(<CreateUserPage />, { renderWithAuth(<CreateUserPage />, {
extraRoutes: [{ path: "/users", element: <div>Users Page</div> }], extraRoutes: [
{ path: "/deployment/users", element: <div>Users Page</div> },
],
}); });
await waitForLoaderToBeRemoved(); await waitForLoaderToBeRemoved();
}; };

View File

@ -1,6 +1,7 @@
import { authMethods, createUser } from "api/queries/users"; import { authMethods, createUser } from "api/queries/users";
import { displaySuccess } from "components/GlobalSnackbar/utils"; import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Margins } from "components/Margins/Margins"; import { Margins } from "components/Margins/Margins";
import { useDashboard } from "modules/dashboard/useDashboard";
import type { FC } from "react"; import type { FC } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { useMutation, useQuery, useQueryClient } from "react-query"; import { useMutation, useQuery, useQueryClient } from "react-query";
@ -17,6 +18,7 @@ export const CreateUserPage: FC = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const createUserMutation = useMutation(createUser(queryClient)); const createUserMutation = useMutation(createUser(queryClient));
const authMethodsQuery = useQuery(authMethods()); const authMethodsQuery = useQuery(authMethods());
const { showOrganizations } = useDashboard();
return ( return (
<Margins> <Margins>
@ -26,16 +28,25 @@ export const CreateUserPage: FC = () => {
<CreateUserForm <CreateUserForm
error={createUserMutation.error} error={createUserMutation.error}
authMethods={authMethodsQuery.data} isLoading={createUserMutation.isLoading}
onSubmit={async (user) => { onSubmit={async (user) => {
await createUserMutation.mutateAsync(user); await createUserMutation.mutateAsync({
username: user.username,
name: user.name,
email: user.email,
organization_ids: [user.organization],
login_type: user.login_type,
password: user.password,
user_status: null,
});
displaySuccess("Successfully created user."); displaySuccess("Successfully created user.");
navigate("..", { relative: "path" }); navigate("..", { relative: "path" });
}} }}
onCancel={() => { onCancel={() => {
navigate("..", { relative: "path" }); navigate("..", { relative: "path" });
}} }}
isLoading={createUserMutation.isLoading} authMethods={authMethodsQuery.data}
showOrganizations={showOrganizations}
/> />
</Margins> </Margins>
); );