Skip to content

Commit

Permalink
Issue 215 frontend custom add new event item type field (#233)
Browse files Browse the repository at this point in the history
* adding new field-single-select-inputbox.tsx

* adding input box in single select dropdown and adding plus button to push it into options

* fixing Space not working on input box

* Enter key can be used as plus button

* Fixed bugs and changed button CSS

* Refined input field, enhanced / added the functions of adding and removing items

* Improved CSS, blocked duplicate item to be added to the list

* Allowed space as input, strengthened the duplicate input check

* Revised function name and placeholder

* Prevented empty input and reset displayed item when the current item is removed from the list

* Reset displayed text if the current item is selected again, revised CSS

* Finetuned before PR

---------

Co-authored-by: HarryKomah <[email protected]>
Co-authored-by: Dylan To <[email protected]>
  • Loading branch information
3 people authored Apr 29, 2024
1 parent c6c5453 commit 02e8756
Show file tree
Hide file tree
Showing 2 changed files with 230 additions and 4 deletions.
224 changes: 224 additions & 0 deletions src/components/FormFields/field-single-select-inputbox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { Fragment, useState } from "react";
import { Menu, Transition } from "@headlessui/react";
import {
FieldValues,
useController,
UseControllerProps
} from "react-hook-form";
import { FaMinus, FaPlus } from "react-icons/fa6";
import { HiCheck, HiChevronDown } from "react-icons/hi";

import Label from "@/components/FormFields/box-label";
import Error from "@/components/FormFields/error-msg";
import { Option } from "@/types";

export interface FormProps<T extends FieldValues = FieldValues>
extends UseControllerProps<T> {
options: Option[];
width?: string;
height?: string;
placeholder?: string;
label?: string;
}

function classNames(...classes: string[]) {
return classes.filter(Boolean).join(" ");
}

/*
This is a component for a dropdown menu
Will display a dropdown displaying options inputted
Input:
options: { id: number; text: string }[]; // array of objects with option text and id number
placeholder: string; // placeholder string before any option is selected
label?: string; // text on border of button
Output:
A dropdown that is compatible w/ React-hook-forms
*/

export default function FieldSingleSelectInput<
T extends FieldValues = FieldValues
>({
options: initialOptions,
placeholder,
label,
width = "w-full",
height = "h-10",
...props
}: FormProps<T>) {
const { field, fieldState } = useController(props);
const [dynamicOptions, setDynamicOptions] = useState<Option[]>(
initialOptions.filter((option) => option.id !== "input-box-add")
);
const [newItemName, setNewItemName] = useState("");
const [displayText, setDisplayText] = useState("");
const [addError, setAddError] = useState("");

const baseStyle = `flex ${height} ${width} justify-between overflow-hidden rounded-lg bg-white px-3 py-2.5 text-sm font-medium text-gray-900 shadow-sm ring-1 ring-inset hover:shadow-grey-300`;
const normalBorderStyle = `ring-grey-300`;
const errorBorderStyle = `ring-red-500`;

const updateOptions = () => {
const trimmedItemName = newItemName.trim();
// Prevent adding if the name is empty or only contains spaces
if (!trimmedItemName) {
setAddError("Item name cannot be empty");
return;
}

// Check for duplicates (trimmed and case-insensitive)
const isDuplicate = dynamicOptions.some(
(option) =>
option.text.trim().toLowerCase() === trimmedItemName.toLowerCase()
);

// Set an error message if the item already exists or add it if it doesn't
if (isDuplicate) {
setAddError("Item already exists");
} else {
const newItem: Option = {
id: `added-item-${Date.now()}`,
text: trimmedItemName
};
setDynamicOptions((prevOptions) => [...prevOptions, newItem]);
setNewItemName("");
setAddError("");
}
};

const handleOptionSelect = (option: Option) => {
// Toggle selection if the same item is selected again
if (option.text === displayText) {
setDisplayText("");
field.onChange("");
} else {
setDisplayText(option.text);
field.onChange(option.text);
}
};

const removeOption = (id: string) => {
// Check if the removed option is the currently selected one
const isCurrentlySelected =
dynamicOptions.find((option) => option.id === id)?.text === displayText;

// Update the options list by filtering out the removed item
setDynamicOptions((options) =>
options.filter((option) => option.id !== id)
);

// If the removed item was selected, reset the display text and form field value
if (isCurrentlySelected) {
setDisplayText("");
field.onChange("");
}
};

const renderMenuItem = (option: Option) => (
<Menu.Item key={option.id}>
{({ active }) => (
<div className="flex justify-between items-center">
<button
onClick={() => handleOptionSelect(option)}
className={classNames(
active ? "bg-lightAqua-100 text-grey-900" : "text-grey-900",
"flex-grow text-left py-2 pl-2 pr-4 text-sm"
)}
>
{option.text === displayText && (
<HiCheck
className="inline h-5 w-5 text-app-primary mr-2"
aria-hidden="true"
/>
)}
{option.text}
</button>
{typeof option.id === "string" &&
option.id.startsWith("added-item-") && (
<button
onClick={(e) => {
e.stopPropagation(); // Prevent menu close
removeOption(option.id as string);
}}
className="mx-2 rounded-md bg-app-accent hover:bg-app-accent-focus p-2 text-white flex-shrink-0"
aria-label="Remove item"
>
<FaMinus />
</button>
)}
</div>
)}
</Menu.Item>
);

return (
<div className={`relative mb-2 inline-block ${width} text-left`}>
<Menu as="div">
<div>
<Menu.Button
className={classNames(
baseStyle,
fieldState.invalid ? errorBorderStyle : normalBorderStyle
)}
>
<Label label={!label ? props.name : label} {...props} />
{fieldState.invalid && <Error {...props} />}
{/* We may need one more light grey color in the latest colour palette, as the grey colors in the palette (base series) are not consistent with those of the placeholders in the event form */}
<span
className="text-gray-500"
style={{ fontFamily: "Arial, sans-serif" }}
>
{displayText || placeholder}
</span>{" "}
<HiChevronDown
className="ml-auto h-6 w-5 text-grey-600"
aria-hidden="true"
/>
</Menu.Button>
</div>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-100"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<Menu.Items className="absolute left-0 z-10 mt-2 w-full origin-top-right overflow-auto rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none">
<div className="py-1">
{dynamicOptions.map(renderMenuItem)}
<div className="flex px-2 py-2 border-t border-gray-200 items-center">
<input
type="text"
value={newItemName}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
updateOptions();
e.stopPropagation();
} else if (e.key === " ") {
e.stopPropagation();
}
}}
onChange={(e) => setNewItemName(e.target.value)}
className="text-sm border-gray-300 shadow flex-grow p-1"
placeholder="Add new item..."
/>
{addError && (
<p className="text-red-500 text-sm ml-2">{addError}</p>
)}
<button
onClick={updateOptions}
className="ml-2 rounded-md bg-app-primary hover:bg-app-primary-focus p-2 text-white flex-shrink-0"
>
<FaPlus />
</button>
</div>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
);
}
10 changes: 6 additions & 4 deletions src/components/Forms/event-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SubmitHandler, useForm } from "react-hook-form";

import Button from "@/components/Button";
import FieldInput from "@/components/FormFields/field-input";
import FieldSingleSelect from "@/components/FormFields/field-single-select";
import FieldSingleSelectInput from "@/components/FormFields/field-single-select-inputbox";
import FieldTextArea from "@/components/FormFields/field-text-area";
import { createEventSchema } from "@/schema/event";
import { CreateEvent } from "@/types";
Expand All @@ -29,7 +29,8 @@ export default function EventForm({
// volunteers: [""]
}
});

const inputBox: ItemType = { name: "input-box-add" };
itemTypes = [...itemTypes, inputBox];
// TODO: Change the startDate and endDate input fields to use a date picker component.
return (
<>
Expand All @@ -41,15 +42,16 @@ export default function EventForm({
label="Event Name"
></FieldInput>

<FieldSingleSelect
<FieldSingleSelectInput
control={control}
name="eventType"
label="Event Type"
placeholder="Select type"
options={itemTypes.map((type) => ({
id: type.name,
text: type.name
}))}
></FieldSingleSelect>
></FieldSingleSelectInput>

<FieldTextArea
name="description"
Expand Down

0 comments on commit 02e8756

Please sign in to comment.