From 195e89f8a6cb7f1a67381311f9a50ca0c6316c59 Mon Sep 17 00:00:00 2001 From: Ryan Laurie <30528226+iethree@users.noreply.github.com> Date: Tue, 6 Aug 2024 17:01:19 -0600 Subject: [PATCH] Allow edits to users with empty name fields (#46511) * allow edits to uers with empty name fields * add repro for 40750 * whoopsie --- .../{ => EditUserModal}/EditUserModal.tsx | 26 ++-- .../people/containers/EditUserModal/index.ts | 1 + .../EditUserModal/test/common.unit.spec.tsx | 138 ++++++++++++++++++ .../test/enterprise.unit.spec.tsx | 80 ++++++++++ .../containers/EditUserModal/test/setup.tsx | 66 +++++++++ .../metabase/admin/people/forms/UserForm.tsx | 8 +- .../test/__support__/server-mocks/user.ts | 1 + 7 files changed, 306 insertions(+), 14 deletions(-) rename frontend/src/metabase/admin/people/containers/{ => EditUserModal}/EditUserModal.tsx (63%) create mode 100644 frontend/src/metabase/admin/people/containers/EditUserModal/index.ts create mode 100644 frontend/src/metabase/admin/people/containers/EditUserModal/test/common.unit.spec.tsx create mode 100644 frontend/src/metabase/admin/people/containers/EditUserModal/test/enterprise.unit.spec.tsx create mode 100644 frontend/src/metabase/admin/people/containers/EditUserModal/test/setup.tsx diff --git a/frontend/src/metabase/admin/people/containers/EditUserModal.tsx b/frontend/src/metabase/admin/people/containers/EditUserModal/EditUserModal.tsx similarity index 63% rename from frontend/src/metabase/admin/people/containers/EditUserModal.tsx rename to frontend/src/metabase/admin/people/containers/EditUserModal/EditUserModal.tsx index ee913ed2b93..2b176bf48e0 100644 --- a/frontend/src/metabase/admin/people/containers/EditUserModal.tsx +++ b/frontend/src/metabase/admin/people/containers/EditUserModal/EditUserModal.tsx @@ -1,14 +1,14 @@ import { useMemo } from "react"; import type { Params } from "react-router/lib/Router"; -import { useUserQuery } from "metabase/common/hooks"; +import { skipToken, useGetUserQuery } from "metabase/api"; import LoadingAndErrorWrapper from "metabase/components/LoadingAndErrorWrapper"; import ModalContent from "metabase/components/ModalContent"; import Users from "metabase/entities/users"; import { useDispatch } from "metabase/lib/redux"; import type { User as UserType } from "metabase-types/api"; -import { UserForm } from "../forms/UserForm"; +import { UserForm } from "../../forms/UserForm"; interface EditUserModalProps { onClose: () => void; @@ -17,15 +17,21 @@ interface EditUserModalProps { export const EditUserModal = ({ onClose, params }: EditUserModalProps) => { const dispatch = useDispatch(); - - const { data: user, isLoading } = useUserQuery({ - id: parseInt(params.userId), - }); + const userId = params.userId ? parseInt(params.userId) : null; + const { data: user, isLoading } = useGetUserQuery(userId ?? skipToken); const initialValues = useMemo(() => getInitialValues(user), [user]); - const handleSubmit = async (val: Partial<UserType>) => { - await dispatch(Users.actions.update({ id: user?.id, ...val })); + const handleSubmit = async (newValues: Partial<UserType>) => { + // first name and last name keys need to be present so they can + // potentially be removed + const submitValues = { + first_name: null, + last_name: null, + ...newValues, + }; + // can't use metabase/api hook here until the people list uses it too + await dispatch(Users.actions.update({ id: user?.id, ...submitValues })); onClose(); }; @@ -48,8 +54,8 @@ export const EditUserModal = ({ onClose, params }: EditUserModalProps) => { const getInitialValues = (user?: UserType) => { return { - first_name: user?.first_name ?? "", - last_name: user?.last_name ?? "", + first_name: user?.first_name, + last_name: user?.last_name, email: user?.email, user_group_memberships: user?.user_group_memberships || [], login_attributes: user?.login_attributes || {}, diff --git a/frontend/src/metabase/admin/people/containers/EditUserModal/index.ts b/frontend/src/metabase/admin/people/containers/EditUserModal/index.ts new file mode 100644 index 00000000000..2b17c3c98c7 --- /dev/null +++ b/frontend/src/metabase/admin/people/containers/EditUserModal/index.ts @@ -0,0 +1 @@ +export * from "./EditUserModal"; diff --git a/frontend/src/metabase/admin/people/containers/EditUserModal/test/common.unit.spec.tsx b/frontend/src/metabase/admin/people/containers/EditUserModal/test/common.unit.spec.tsx new file mode 100644 index 00000000000..6d018f90c95 --- /dev/null +++ b/frontend/src/metabase/admin/people/containers/EditUserModal/test/common.unit.spec.tsx @@ -0,0 +1,138 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import fetchMock from "fetch-mock"; + +import { setup, defaultUser } from "./setup"; + +describe("Edit user modal", () => { + it("should populate existing data", async () => { + setup({ userData: defaultUser }); + expect(await screen.findByText("Edit user")).toBeInTheDocument(); + expect(await screen.findByDisplayValue("Ash")).toBeInTheDocument(); + expect(await screen.findByDisplayValue("Ketchum")).toBeInTheDocument(); + expect( + await screen.findByDisplayValue("pikachuboy97@example.com"), + ).toBeInTheDocument(); + }); + + it("should close the modal when the close button is clicked", async () => { + const { onCloseSpy } = setup({ userData: defaultUser }); + await userEvent.click(await screen.findByText("Cancel")); + expect(onCloseSpy).toHaveBeenCalled(); + }); + + it("should send name updates to the API", async () => { + setup({ userData: defaultUser }); + const firstNameField = await screen.findByLabelText("First name"); + const submitButton = await screen.findByText("Update"); + + await userEvent.clear(firstNameField); + await userEvent.type(firstNameField, "Misty"); + + expect(submitButton).toBeEnabled(); + + await userEvent.click(submitButton); + + const call = fetchMock.lastCall("path:/api/user/97", { method: "PUT" }); + + const req = await call?.request?.json(); + + expect(req).toEqual({ + first_name: "Misty", + last_name: "Ketchum", + email: "pikachuboy97@example.com", + user_group_memberships: [], + login_attributes: {}, + }); + }); + + describe("users with empty name fields (metabase#46446)", () => { + it("can update the first name of a user with no last name", async () => { + setup({ + userData: { + id: 11, + first_name: "Prince", + last_name: null, + email: "name@example.com", + }, + }); + + const firstNameField = await screen.findByLabelText("First name"); + const submitButton = await screen.findByText("Update"); + + await userEvent.clear(firstNameField); + await userEvent.type(firstNameField, "Madonna"); + await userEvent.click(submitButton); + + const call = fetchMock.lastCall("path:/api/user/11", { method: "PUT" }); + const req = await call?.request?.json(); + + expect(req).toEqual({ + first_name: "Madonna", + last_name: null, // this null key must be present + email: "name@example.com", + user_group_memberships: [], + login_attributes: {}, + }); + }); + + it("can update the email of a user with no name", async () => { + setup({ + userData: { + id: 11, + first_name: null, + last_name: null, + email: "neo@example.com", + }, + }); + + const emailField = await screen.findByLabelText("Email *"); + const submitButton = await screen.findByText("Update"); + + await userEvent.clear(emailField); + await userEvent.type(emailField, "morpheus@example.com"); + await userEvent.click(submitButton); + + const call = fetchMock.lastCall("path:/api/user/11", { method: "PUT" }); + const req = await call?.request?.json(); + + expect(req).toEqual({ + first_name: null, // this null key must be present + last_name: null, // this null key must be present + email: "morpheus@example.com", + user_group_memberships: [], + login_attributes: {}, + }); + }); + + it("can remove a user's name", async () => { + setup({ + userData: { + id: 11, + first_name: "Simon", + last_name: "Garfunkel", + email: "s+g@example.com", + }, + }); + + const firstNameField = await screen.findByLabelText("First name"); + const lastNameField = await screen.findByLabelText("Last name"); + const submitButton = await screen.findByText("Update"); + + await userEvent.clear(firstNameField); + await userEvent.clear(lastNameField); + await userEvent.click(submitButton); + + const call = fetchMock.lastCall("path:/api/user/11", { method: "PUT" }); + const req = await call?.request?.json(); + + expect(req).toEqual({ + first_name: null, // this null key must be present + last_name: null, // this null key must be present + email: "s+g@example.com", + user_group_memberships: [], + login_attributes: {}, + }); + }); + }); +}); diff --git a/frontend/src/metabase/admin/people/containers/EditUserModal/test/enterprise.unit.spec.tsx b/frontend/src/metabase/admin/people/containers/EditUserModal/test/enterprise.unit.spec.tsx new file mode 100644 index 00000000000..fb08821eb41 --- /dev/null +++ b/frontend/src/metabase/admin/people/containers/EditUserModal/test/enterprise.unit.spec.tsx @@ -0,0 +1,80 @@ +import { screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import fetchMock from "fetch-mock"; + +import { setup, defaultUser } from "./setup"; + +describe("EditUserModal - enterprise", () => { + it("can add a user attribute", async () => { + setup({ userData: defaultUser, hasEnterprisePlugins: true }); + + const submitButton = await screen.findByText("Update"); + const addAttributeButton = await screen.findByText("Add an attribute"); + + await userEvent.click(addAttributeButton); + + await userEvent.type( + await screen.findByPlaceholderText("Key"), + "favorite_pokemon", + ); + await userEvent.type( + await screen.findByPlaceholderText("Value"), + "Pikachu", + ); + + await userEvent.click(submitButton); + + const call = fetchMock.lastCall("path:/api/user/97", { method: "PUT" }); + const req = await call?.request?.json(); + + expect(req).toEqual({ + first_name: "Ash", + last_name: "Ketchum", + email: "pikachuboy97@example.com", + user_group_memberships: [], + login_attributes: { + favorite_pokemon: "Pikachu", + }, + }); + }); + + it("can add a user attribute to a user with no name (metabase#40750)", async () => { + setup({ + userData: { + ...defaultUser, + first_name: null, + last_name: null, + }, + hasEnterprisePlugins: true, + }); + + const submitButton = await screen.findByText("Update"); + const addAttributeButton = await screen.findByText("Add an attribute"); + + await userEvent.click(addAttributeButton); + + await userEvent.type( + await screen.findByPlaceholderText("Key"), + "favorite_pokemon", + ); + await userEvent.type( + await screen.findByPlaceholderText("Value"), + "Pikachu", + ); + + await userEvent.click(submitButton); + + const call = fetchMock.lastCall("path:/api/user/97", { method: "PUT" }); + const req = await call?.request?.json(); + + expect(req).toEqual({ + first_name: null, + last_name: null, + email: "pikachuboy97@example.com", + user_group_memberships: [], + login_attributes: { + favorite_pokemon: "Pikachu", + }, + }); + }); +}); diff --git a/frontend/src/metabase/admin/people/containers/EditUserModal/test/setup.tsx b/frontend/src/metabase/admin/people/containers/EditUserModal/test/setup.tsx new file mode 100644 index 00000000000..4453bdc2627 --- /dev/null +++ b/frontend/src/metabase/admin/people/containers/EditUserModal/test/setup.tsx @@ -0,0 +1,66 @@ +import fetchMock from "fetch-mock"; + +import { setupEnterprisePlugins } from "__support__/enterprise"; +import { setupUserEndpoints } from "__support__/server-mocks"; +import { mockSettings } from "__support__/settings"; +import { createMockEntitiesState } from "__support__/store"; +import { renderWithProviders } from "__support__/ui"; +import type { User, UserListResult } from "metabase-types/api"; +import { + createMockUser, + createMockSettings, + createMockTokenFeatures, +} from "metabase-types/api/mocks"; +import { createMockState } from "metabase-types/store/mocks"; + +import { EditUserModal } from "../EditUserModal"; + +export const defaultUser = createMockUser({ + id: 97, + first_name: "Ash", + last_name: "Ketchum", + email: "pikachuboy97@example.com", + login_attributes: {}, +}); + +export const setup = ({ + userData, + hasEnterprisePlugins = false, +}: { + userData: Partial<User>; + hasEnterprisePlugins?: boolean; +}) => { + setupUserEndpoints(createMockUser(userData) as unknown as UserListResult); + + const storeInitialState = createMockState({ + entities: createMockEntitiesState({}), + settings: mockSettings( + createMockSettings({ + "token-features": createMockTokenFeatures({ + advanced_permissions: true, + sandboxes: true, + }), + }), + ), + }); + + if (hasEnterprisePlugins) { + setupEnterprisePlugins(); + } + + fetchMock.get("path:/api/permissions/group", []); + fetchMock.get("path:/api/permissions/membership", {}); + + // setupPermissionsGraphEndpoints([]); + const onCloseSpy = jest.fn(); + renderWithProviders( + <EditUserModal + params={{ userId: String(userData.id) }} + onClose={onCloseSpy} + />, + { + storeInitialState, + }, + ); + return { onCloseSpy }; +}; diff --git a/frontend/src/metabase/admin/people/forms/UserForm.tsx b/frontend/src/metabase/admin/people/forms/UserForm.tsx index 0c107288afa..d779f23327a 100644 --- a/frontend/src/metabase/admin/people/forms/UserForm.tsx +++ b/frontend/src/metabase/admin/people/forms/UserForm.tsx @@ -15,9 +15,9 @@ import { PLUGIN_ADMIN_USER_FORM_FIELDS } from "metabase/plugins"; import { Button } from "metabase/ui"; import type { User } from "metabase-types/api"; -const localUserScmeha = Yup.object({ - first_name: Yup.string().max(100, Errors.maxLength).default(""), - last_name: Yup.string().max(100, Errors.maxLength).default(""), +const localUserSchema = Yup.object({ + first_name: Yup.string().nullable().max(100, Errors.maxLength).default(null), + last_name: Yup.string().nullable().max(100, Errors.maxLength).default(null), email: Yup.string().email().required(Errors.required), }); @@ -37,7 +37,7 @@ export const UserForm = ({ return ( <FormProvider initialValues={initialValues} - validationSchema={localUserScmeha} + validationSchema={localUserSchema} enableReinitialize onSubmit={onSubmit} > diff --git a/frontend/test/__support__/server-mocks/user.ts b/frontend/test/__support__/server-mocks/user.ts index 3cb10c2cabf..6c8cba4bf2b 100644 --- a/frontend/test/__support__/server-mocks/user.ts +++ b/frontend/test/__support__/server-mocks/user.ts @@ -5,6 +5,7 @@ import type { User, UserAttribute, UserListResult } from "metabase-types/api"; export function setupUserEndpoints(user: UserListResult) { fetchMock.get(`path:/api/user/${user.id}`, user); + fetchMock.put(`path:/api/user/${user.id}`, user); } export function setupUsersEndpoints(users: UserListResult[]) { -- GitLab