Skip to content

Commit

Permalink
Self review feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
hariombalhara committed Jan 7, 2025
1 parent 0b706c9 commit bdd7295
Show file tree
Hide file tree
Showing 2 changed files with 241 additions and 24 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { zodResolver } from "@hookform/resolvers/zod";
import React, { useState } from "react";
import type { UseFormReturn, FieldArrayWithId } from "react-hook-form";
import { Controller, useForm, useFieldArray } from "react-hook-form";
import { z } from "zod";

Expand All @@ -16,7 +17,7 @@ import {
SettingsToggle,
} from "@calcom/ui";

const AttributeFormSchema = z.object({
const attributeFormSchema = z.object({
attrName: z.string().min(1),
isLocked: z.boolean().optional(),
isWeightsEnabled: z.boolean().optional(),
Expand All @@ -33,7 +34,7 @@ const AttributeFormSchema = z.object({
),
});

type FormValues = z.infer<typeof AttributeFormSchema>;
type AttributeFormValues = z.infer<typeof attributeFormSchema>;

const AttributeTypeOptions = [
{ value: "TEXT", label: "Text" },
Expand All @@ -43,8 +44,8 @@ const AttributeTypeOptions = [
];

interface AttributeFormProps {
initialValues?: FormValues;
onSubmit: (values: FormValues) => void;
initialValues?: AttributeFormValues;
onSubmit: (values: AttributeFormValues) => void;
header: React.ReactNode;
}

Expand Down Expand Up @@ -130,13 +131,13 @@ const NonGroupOption = ({
option,
index,
form,
remove,
removeOption,
setDeleteOptionDialog,
}: {
option: AttributeOption;
index: number;
form: any;
remove: (index: number) => void;
form: UseFormReturn<AttributeFormValues>;
removeOption: (index: number) => void;
setDeleteOptionDialog: (value: { id: number; open: boolean }) => void;
}) => {
const { t } = useLocale();
Expand All @@ -157,7 +158,7 @@ const NonGroupOption = ({
if (option.assignedUsers && option.assignedUsers > 0) {
setDeleteOptionDialog({ id: index, open: true });
} else {
remove(index);
removeOption(index);
}
}}
title={t("remove_option")}
Expand Down Expand Up @@ -194,13 +195,13 @@ const GroupOption = ({
option,
index,
form,
remove,
removeOption,
setDeleteOptionDialog,
}: {
option: AttributeOption;
index: number;
form: any;
remove: (index: number) => void;
form: UseFormReturn<AttributeFormValues>;
removeOption: (index: number) => void;
setDeleteOptionDialog: (value: { id: number; open: boolean }) => void;
}) => {
const { t } = useLocale();
Expand All @@ -220,7 +221,6 @@ const GroupOption = ({
<Input {...form.register(`options.${index}.value`)} className="!mb-0 w-36" />
<SelectField
isMulti
isDisabled={!option.attributeOptionId}
placeholder={t("choose_an_option")}
options={nonGroupOptionsSelectFieldOptions}
value={nonGroupOptionsSelectFieldSelectedValue}
Expand All @@ -242,7 +242,7 @@ const GroupOption = ({
if (option.assignedUsers && option.assignedUsers > 0) {
setDeleteOptionDialog({ id: index, open: true });
} else {
remove(index);
removeOption(index);
}
}}
/>
Expand All @@ -256,13 +256,13 @@ const GroupOptions = ({
fields,
watchedOptions,
form,
remove,
removeOption,
setDeleteOptionDialog,
}: {
fields: any[];
fields: FieldArrayWithId<AttributeFormValues, "options", "id">[];
watchedOptions: AttributeOption[];
form: any;
remove: (index: number) => void;
form: UseFormReturn<AttributeFormValues>;
removeOption: (index: number) => void;
setDeleteOptionDialog: (value: { id: number; open: boolean }) => void;
}) => {
const { t } = useLocale();
Expand All @@ -279,7 +279,7 @@ const GroupOptions = ({
option={watchedOptions[index]}
index={index}
form={form}
remove={remove}
removeOption={removeOption}
setDeleteOptionDialog={setDeleteOptionDialog}
/>
);
Expand All @@ -299,7 +299,7 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
open: false,
});

// Needed because useFieldArray overrides the id field
// Needed because fields returned by useFieldArray have their own id field overriding the id field of the option object.
const initialValuesEnsuringThatOptionIdIsNotInId = initialValues
? {
...initialValues,
Expand All @@ -313,8 +313,8 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
}
: undefined;

const form = useForm<FormValues>({
resolver: zodResolver(AttributeFormSchema),
const form = useForm<AttributeFormValues>({
resolver: zodResolver(attributeFormSchema),
defaultValues: initialValuesEnsuringThatOptionIdIsNotInId || {
attrName: "",
options: [{ value: "", isGroup: false }],
Expand All @@ -327,6 +327,39 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
name: "options",
});

const removeOption = (index: number) => {
// Update contains array of any group that has this option
const optionToRemove = watchedOptions[index];
if (!optionToRemove.isGroup && optionToRemove.attributeOptionId) {
const updatedOptions = getUpdatedOptionsAfterRemovingNonGroupOption({
optionToRemove,
watchedOptions,
});
form.setValue("options", updatedOptions);
}
remove(index);
};

const getUpdatedOptionsAfterRemovingNonGroupOption = ({
optionToRemove,
watchedOptions,
}: {
optionToRemove: AttributeOption;
watchedOptions: AttributeOption[];
}) => {
const attributeOptionIdToRemove = optionToRemove.attributeOptionId;
if (!attributeOptionIdToRemove) return watchedOptions;
return watchedOptions.map((option) => {
if (option.isGroup && option.contains) {
return {
...option,
contains: option.contains.filter((id) => id !== attributeOptionIdToRemove),
};
}
return option;
});
};

const watchedOptions = form.watch("options") as AttributeOption[];
const watchedType = form.watch("type");
return (
Expand Down Expand Up @@ -407,7 +440,7 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
option={watchedOptions[index]}
index={index}
form={form}
remove={remove}
removeOption={removeOption}
setDeleteOptionDialog={setDeleteOptionDialog}
/>
);
Expand All @@ -422,7 +455,7 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
fields={fields}
watchedOptions={watchedOptions}
form={form}
remove={remove}
removeOption={removeOption}
setDeleteOptionDialog={setDeleteOptionDialog}
/>
<Button
Expand All @@ -443,7 +476,7 @@ export function AttributeForm({ initialValues, onSubmit, header }: AttributeForm
title={t("delete_attribute")}
confirmBtnText={t("delete")}
onConfirm={() => {
remove(deleteOptionDialog.id as number);
removeOption(deleteOptionDialog.id as number);
setDeleteOptionDialog({ id: undefined, open: false });
}}
loadingText={t("deleting_attribute")}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import React from "react";
import type { Mock } from "vitest";
import { vi } from "vitest";

import { Button } from "@calcom/ui";

import { AttributeForm } from "../AttributesForm";

vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: vi.fn(() => ({
t: (key: string) => key,
})),
}));

type InitialOption = {
id?: string;
value: string;
isGroup?: boolean;
contains?: string[];
attributeOptionId?: string;
assignedUsers?: number;
};

// Page Object Functions
const AttributeFormActions = {
setup: () => {
const user = userEvent.setup();
const mockOnSubmit = vi.fn();
return { user, mockOnSubmit };
},

render: (initialOptions: InitialOption[], mockOnSubmit: Mock) => {
return render(
<AttributeForm
onSubmit={mockOnSubmit}
initialValues={{
attrName: "Teams",
type: "MULTI_SELECT",
options: initialOptions,
}}
header={<Button type="submit">Save</Button>}
/>
);
},

addOptionToGroup: async (user: ReturnType<typeof userEvent.setup>) => {
const selects = await screen.findAllByText("choose_an_option");
await user.click(selects[0]);
},

selectOption: async (user: ReturnType<typeof userEvent.setup>, optionText: string) => {
const option = await screen.findByText(optionText);
await user.click(option);
},

submitForm: async (user: ReturnType<typeof userEvent.setup>) => {
const submitButton = screen.getByRole("button", { name: "Save" });
await user.click(submitButton);
},

deleteOption: async (user: ReturnType<typeof userEvent.setup>, index: number) => {
const deleteButtons = screen.getAllByTitle("remove_option");
await user.click(deleteButtons[index]);
},

expectGroupContainsOptions: async (mockOnSubmit: Mock, groupValue: string, containsIds: string[]) => {
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
value: groupValue,
isGroup: true,
contains: expect.arrayContaining(containsIds),
}),
]),
})
);
});
},

expectGroupNotContainsOptions: async (mockOnSubmit: Mock, groupValue: string, notContainsIds: string[]) => {
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({
value: groupValue,
isGroup: true,
contains: expect.not.arrayContaining(notContainsIds),
}),
]),
})
);
});
},

expectDeleteConfirmationDialog: () => {
expect(screen.getByText("delete_attribute")).toBeInTheDocument();
expect(screen.getByText("delete_attribute_description")).toBeInTheDocument();
},

expectOptionToBeDeleted: async (mockOnSubmit: Mock, optionValue: string) => {
await waitFor(() => {
expect(mockOnSubmit).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.not.arrayContaining([
expect.objectContaining({
value: optionValue,
}),
]),
})
);
});
},
};

const initialOptionsWithAGroupHavingOptions: InitialOption[] = [
{ id: "1", value: "Engineering", isGroup: false, attributeOptionId: "1" },
{ id: "2", value: "Design", isGroup: false, attributeOptionId: "2" },
{ id: "3", value: "Product", isGroup: false, attributeOptionId: "3" },
{
id: "4",
value: "Tech Teams",
isGroup: true,
contains: ["1", "3"],
attributeOptionId: "4",
},
];

const initialOptionsWithAGroupHavingNoOptions: InitialOption[] = [
{ id: "1", value: "Engineering", isGroup: false, attributeOptionId: "1" },
{ id: "2", value: "Design", isGroup: false, attributeOptionId: "2" },
{ id: "3", value: "Product", isGroup: false, attributeOptionId: "3" },
{
id: "4",
value: "Tech Teams",
isGroup: true,
contains: [],
attributeOptionId: "4",
},
];

describe("AttributeForm", () => {
describe("Group Options Handling", () => {
it("should handle adding non-group options to existing group", async () => {
const { user, mockOnSubmit } = AttributeFormActions.setup();
AttributeFormActions.render(initialOptionsWithAGroupHavingNoOptions, mockOnSubmit);

await AttributeFormActions.addOptionToGroup(user);
await AttributeFormActions.selectOption(user, "Design");
await AttributeFormActions.submitForm(user);

await AttributeFormActions.expectGroupContainsOptions(mockOnSubmit, "Tech Teams", ["2"]);
});

it.only("should handle removing options from group when the option is deleted", async () => {
const { user, mockOnSubmit } = AttributeFormActions.setup();
AttributeFormActions.render(initialOptionsWithAGroupHavingOptions, mockOnSubmit);

await AttributeFormActions.deleteOption(user, 0);
await AttributeFormActions.submitForm(user);
await AttributeFormActions.expectGroupNotContainsOptions(mockOnSubmit, "Tech Teams", ["1"]);
await AttributeFormActions.expectOptionToBeDeleted(mockOnSubmit, "Engineering");
});

it("should take confirmation before deleting option with assigned users", async () => {
const { user, mockOnSubmit } = AttributeFormActions.setup();
const optionsWithAssignedUsers = initialOptionsWithAGroupHavingOptions.map((opt) =>
opt.value === "Engineering" ? { ...opt, assignedUsers: 5 } : opt
);

AttributeFormActions.render(optionsWithAssignedUsers, mockOnSubmit);
await AttributeFormActions.deleteOption(user, 0);
AttributeFormActions.expectDeleteConfirmationDialog();
const confirmationButton = screen.getByTestId("dialog-confirmation");
await user.click(confirmationButton);
await AttributeFormActions.submitForm(user);
await AttributeFormActions.expectGroupNotContainsOptions(mockOnSubmit, "Tech Teams", ["1"]);
});
});
});

0 comments on commit bdd7295

Please sign in to comment.