refactor: attribute form component

This commit is contained in:
Aaryan Khandelwal 2023-09-15 16:24:20 +05:30
parent e713db48b3
commit 57f4941ee2
8 changed files with 206 additions and 127 deletions

View File

@ -1,19 +1,29 @@
import React, { useState } from "react";
import React, { useRef, useState } from "react";
// headless ui
import { Combobox, Transition } from "@headlessui/react";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// icons
import { Search } from "lucide-react";
import { Check, Search } from "lucide-react";
// types
import { Props } from "./types";
export const CustomSelectAttribute: React.FC<Props & { value: string | undefined }> = ({
attributeDetails,
onChange,
value,
}) => {
export const CustomSelectAttribute: React.FC<
Props &
(
| { multiple?: false; value: string | undefined }
| { multiple?: true; value: string[] | undefined }
)
> = (props) => {
const { attributeDetails, multiple = false, onChange, value } = props;
const [isOpen, setIsOpen] = useState(false);
const [query, setQuery] = useState("");
const dropdownButtonRef = useRef<HTMLButtonElement>(null);
const dropdownOptionsRef = useRef<HTMLUListElement>(null);
const selectedOption =
attributeDetails.children.find((option) => option.id === value) ??
attributeDetails.children.find((option) => option.is_default);
@ -22,66 +32,115 @@ export const CustomSelectAttribute: React.FC<Props & { value: string | undefined
option.display_name.toLowerCase().includes(query.toLowerCase())
);
const handleOnOpen = () => {
const dropdownButton = dropdownButtonRef.current;
const dropdownOptions = dropdownOptionsRef.current;
if (!dropdownButton || !dropdownOptions) return;
const dropdownWidth = dropdownOptions.clientWidth;
const dropdownHeight = dropdownOptions.clientHeight;
const dropdownBtnX = dropdownButton.getBoundingClientRect().x;
const dropdownBtnY = dropdownButton.getBoundingClientRect().y;
const dropdownBtnHeight = dropdownButton.clientHeight;
let top = dropdownBtnY + dropdownBtnHeight;
if (dropdownBtnY + dropdownHeight > window.innerHeight)
top = dropdownBtnY - dropdownHeight - 10;
else top = top + 4;
let left = dropdownBtnX;
if (dropdownBtnX + dropdownWidth > window.innerWidth) left = dropdownBtnX - dropdownWidth;
dropdownOptions.style.top = `${Math.round(top)}px`;
dropdownOptions.style.left = `${Math.round(left)}px`;
};
useOutsideClickDetector(dropdownOptionsRef, () => {
if (isOpen) setIsOpen(false);
});
const comboboxProps: any = {
value,
onChange,
};
if (multiple) comboboxProps.multiple = true;
return (
<Combobox
as="div"
value={value}
onChange={(val) => onChange(val)}
className="relative flex-shrink-0 text-left"
>
{({ open }: { open: boolean }) => (
<>
<Combobox.Button
className={`flex items-center text-xs rounded px-2.5 py-0.5 truncate w-min max-w-full text-left ${
selectedOption ? "" : "bg-custom-background-80"
}`}
style={{
backgroundColor: `${selectedOption?.color}40`,
}}
>
{selectedOption?.display_name ?? "Select"}
</Combobox.Button>
<Transition
show={open}
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<Combobox.Options className="fixed z-10 mb-2 border-[0.5px] border-custom-border-300 p-1 min-w-[10rem] max-h-60 max-w-[10rem] rounded bg-custom-background-100 text-xs shadow-custom-shadow-rg focus:outline-none mt-1 flex flex-col overflow-hidden">
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2 mb-1">
<Search className="text-custom-text-400" size={12} strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
<Combobox as="div" className="flex-shrink-0 text-left" {...comboboxProps}>
{({ open }: { open: boolean }) => {
if (open) handleOnOpen();
return (
<>
<Combobox.Button
ref={dropdownButtonRef}
className={`flex items-center text-xs rounded px-2.5 py-0.5 truncate w-min max-w-full text-left ${
selectedOption ? "" : "bg-custom-background-80"
}`}
style={{
backgroundColor: `${selectedOption?.color}40`,
}}
>
{selectedOption?.display_name ?? "Select"}
</Combobox.Button>
<Transition
as={React.Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<div className="fixed z-10 top-0 left-0 h-full w-full cursor-auto">
<Combobox.Options
ref={dropdownOptionsRef}
className="absolute z-10 border border-custom-border-300 px-2 py-2.5 rounded bg-custom-background-100 text-xs shadow-lg focus:outline-none w-48 whitespace-nowrap"
>
<div className="flex w-full items-center justify-start rounded-sm border-[0.6px] border-custom-border-200 bg-custom-background-90 px-2 mb-1">
<Search className="text-custom-text-400" size={12} strokeWidth={1.5} />
<Combobox.Input
className="w-full bg-transparent py-1 px-2 text-xs text-custom-text-200 placeholder:text-custom-text-400 focus:outline-none"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Type to search..."
displayValue={(assigned: any) => assigned?.name}
/>
</div>
<div className="mt-1 overflow-y-auto">
{(options ?? []).map((option) => (
<Combobox.Option
key={option.id}
value={option.id}
className={({ active }) =>
`flex items-center justify-between gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 w-full ${
active ? "bg-custom-background-80" : ""
}`
}
>
{({ selected }) => (
<>
<span
className="px-1 rounded-sm truncate"
style={{ backgroundColor: `${option.color}40` }}
>
{option.display_name}
</span>
{selected && <Check size={14} strokeWidth={1.5} />}
</>
)}
</Combobox.Option>
))}
</div>
</Combobox.Options>
</div>
<div className="mt-1 overflow-y-auto">
{(options ?? []).map((option) => (
<Combobox.Option
key={option.id}
value={option.id}
className="flex items-center gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-80 w-full"
>
<span
className="px-1 rounded-sm truncate"
style={{ backgroundColor: `${option.color}40` }}
>
{option.display_name}
</span>
</Combobox.Option>
))}
</div>
</Combobox.Options>
</Transition>
</>
)}
</Transition>
</>
);
}}
</Combobox>
);
};

View File

@ -1,5 +1,10 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
// mobx
import { observer } from "mobx-react-lite";
import { useMobxStore } from "lib/mobx/store-provider";
// headless ui
import { Disclosure } from "@headlessui/react";
// react-hook-form
@ -17,7 +22,7 @@ import {
UrlAttributeForm,
} from "components/custom-attributes";
// ui
import { PrimaryButton, ToggleSwitch } from "components/ui";
import { PrimaryButton, SecondaryButton, ToggleSwitch } from "components/ui";
// icons
import { ChevronDown } from "lucide-react";
// types
@ -26,9 +31,7 @@ import { ICustomAttribute, TCustomAttributeTypes } from "types";
import { CUSTOM_ATTRIBUTES_LIST } from "constants/custom-attributes";
type Props = {
data: Partial<ICustomAttribute>;
handleDeleteAttribute: () => void;
handleUpdateAttribute: (data: Partial<ICustomAttribute>) => Promise<void>;
attributeDetails: Partial<ICustomAttribute>;
objectId: string;
type: TCustomAttributeTypes;
};
@ -56,7 +59,9 @@ const RenderForm: React.FC<{ type: TCustomAttributeTypes } & FormComponentProps>
else if (type === "file")
FormToRender = <FileAttributeForm control={control} objectId={objectId} watch={watch} />;
else if (type === "multi_select")
FormToRender = <SelectAttributeForm control={control} objectId={objectId} watch={watch} />;
FormToRender = (
<SelectAttributeForm control={control} objectId={objectId} watch={watch} multiple />
);
else if (type === "number")
FormToRender = <NumberAttributeForm control={control} objectId={objectId} watch={watch} />;
else if (type === "relation")
@ -71,15 +76,17 @@ const RenderForm: React.FC<{ type: TCustomAttributeTypes } & FormComponentProps>
return FormToRender;
};
export const AttributeForm: React.FC<Props> = ({
data,
handleDeleteAttribute,
handleUpdateAttribute,
objectId,
type,
}) => {
export const AttributeForm: React.FC<Props> = observer(({ attributeDetails, objectId, type }) => {
const [isRemoving, setIsRemoving] = useState(false);
const router = useRouter();
const { workspaceSlug } = router.query;
const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type];
const { customAttributes: customAttributesStore } = useMobxStore();
const { deleteEntityAttribute, updateEntityAttribute } = customAttributesStore;
const {
control,
formState: { isSubmitting },
@ -88,18 +95,30 @@ export const AttributeForm: React.FC<Props> = ({
watch,
} = useForm({ defaultValues: typeMetaData.defaultFormValues });
const handleFormSubmit = async (data: Partial<ICustomAttribute>) => {
await handleUpdateAttribute(data);
const handleUpdateAttribute = async (data: Partial<ICustomAttribute>) => {
if (!workspaceSlug || !attributeDetails.id || !objectId) return;
await updateEntityAttribute(workspaceSlug.toString(), objectId, attributeDetails.id, data);
};
const handleDeleteAttribute = async () => {
if (!workspaceSlug || !attributeDetails.id || !objectId) return;
setIsRemoving(true);
await deleteEntityAttribute(workspaceSlug.toString(), objectId, attributeDetails.id).finally(
() => setIsRemoving(false)
);
};
useEffect(() => {
if (!data) return;
if (!attributeDetails) return;
reset({
...typeMetaData.defaultFormValues,
...data,
...attributeDetails,
});
}, [data, reset, typeMetaData.defaultFormValues]);
}, [attributeDetails, reset, typeMetaData.defaultFormValues]);
return (
<Disclosure
@ -111,20 +130,25 @@ export const AttributeForm: React.FC<Props> = ({
<Disclosure.Button className="p-3 flex items-center justify-between gap-1 w-full">
<div className="flex items-center gap-2.5">
<typeMetaData.icon size={14} strokeWidth={1.5} />
<h6 className="text-sm">{data.display_name ?? typeMetaData.label}</h6>
<h6 className="text-sm">{attributeDetails.display_name ?? typeMetaData.label}</h6>
</div>
<div className={`${open ? "-rotate-180" : ""} transition-all`}>
<ChevronDown size={16} strokeWidth={1.5} rotate="180deg" />
</div>
</Disclosure.Button>
<Disclosure.Panel>
<form onSubmit={handleSubmit(handleFormSubmit)} className="p-3 pl-9 pt-0">
{data.type && (
<RenderForm type={data.type} control={control} objectId={objectId} watch={watch} />
<form onSubmit={handleSubmit(handleUpdateAttribute)} className="p-3 pl-9 pt-0">
{attributeDetails.type && (
<RenderForm
type={attributeDetails.type}
control={control}
objectId={objectId}
watch={watch}
/>
)}
<div className="mt-8 flex items-center justify-between">
<div className="flex-shrink-0 flex items-center gap-2">
{data.type !== "checkbox" && (
{attributeDetails.type !== "checkbox" && (
<>
<Controller
control={control}
@ -138,13 +162,13 @@ export const AttributeForm: React.FC<Props> = ({
)}
</div>
<div className="flex items-center gap-2">
<button
<SecondaryButton
type="button"
onClick={handleDeleteAttribute}
className="text-xs font-medium px-3 py-2 rounded bg-custom-background-100 border border-custom-border-200"
loading={isRemoving}
>
Remove
</button>
{isRemoving ? "Removing..." : "Remove"}
</SecondaryButton>
<PrimaryButton type="submit" loading={isSubmitting}>
{isSubmitting ? "Saving..." : "Save"}
</PrimaryButton>
@ -156,4 +180,4 @@ export const AttributeForm: React.FC<Props> = ({
)}
</Disclosure>
);
};
});

View File

@ -40,7 +40,7 @@ export const RelationAttributeForm: React.FC<FormComponentProps> = ({ control })
</CustomSelect>
)}
/>
<div>
{/* <div>
<p className="text-xs">Selection type</p>
<div className="mt-2 flex items-center gap-6 accent-custom-primary-100">
<div className="flex items-center gap-1 text-xs">
@ -60,6 +60,6 @@ export const RelationAttributeForm: React.FC<FormComponentProps> = ({ control })
<label htmlFor="multiSelect">Multi select</label>
</div>
</div>
</div>
</div> */}
</div>
);

View File

@ -39,7 +39,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
const { entityAttributes, fetchEntityDetails } = customAttributesStore;
const { issueAttributeValues, fetchIssueAttributeValues } = customAttributeValuesStore;
const handleAttributeUpdate = (attributeId: string, value: string) => {
const handleAttributeUpdate = (attributeId: string, value: string[]) => {
if (!issue || !workspaceSlug) return;
const payload: ICustomAttributeValueFormData = {
@ -109,7 +109,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomCheckboxAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={
attributeValue ? (attributeValue?.[0]?.value === "true" ? true : false) : false
@ -120,7 +120,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomDateTimeAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? new Date(attributeValue?.[0]?.value ?? "") : undefined}
/>
@ -129,7 +129,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomEmailAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
@ -138,16 +138,26 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomFileAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
)}
{attribute.type === "multi_select" && (
<CustomSelectAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string[]) => handleAttributeUpdate(attribute.id, val)}
projectId={issue.project}
value={Array.isArray(attributeValue) ? attributeValue.map((v) => v.value) : []}
multiple
/>
)}
{attribute.type === "number" && (
<CustomNumberAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={
attributeValue ? parseInt(attributeValue?.[0]?.value ?? "0", 10) : undefined
@ -158,7 +168,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomRelationAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
@ -167,7 +177,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomSelectAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>
@ -176,7 +186,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomTextAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0].value : undefined}
/>
@ -185,7 +195,7 @@ export const SidebarCustomAttributesList: React.FC<Props> = observer(({ issue })
<CustomUrlAttribute
attributeDetails={attribute}
issueId={issue.id}
onChange={(val: string) => handleAttributeUpdate(attribute.id, val)}
onChange={(val: string) => handleAttributeUpdate(attribute.id, [val])}
projectId={issue.project}
value={attributeValue ? attributeValue?.[0]?.value : undefined}
/>

View File

@ -39,11 +39,9 @@ export const ObjectModal: React.FC<Props> = observer(
createEntity,
createEntityAttribute,
createEntityAttributeLoader,
deleteEntityAttribute,
entityAttributes,
fetchEntityDetails,
fetchEntityDetailsLoader,
updateEntityAttribute,
} = customAttributesStore;
const handleClose = () => {
@ -88,18 +86,6 @@ export const ObjectModal: React.FC<Props> = observer(
await createEntityAttribute(workspaceSlug.toString(), { ...payload, parent: object.id });
};
const handleUpdateAttribute = async (attributeId: string, data: Partial<ICustomAttribute>) => {
if (!workspaceSlug || !object || !object.id) return;
await updateEntityAttribute(workspaceSlug.toString(), object.id, attributeId, data);
};
const handleDeleteAttribute = async (attributeId: string) => {
if (!workspaceSlug || !object || !object.id) return;
await deleteEntityAttribute(workspaceSlug.toString(), object.id, attributeId);
};
// fetch the object details if object state has id
useEffect(() => {
if (!object.id || object.id === "") return;
@ -193,11 +179,7 @@ export const ObjectModal: React.FC<Props> = observer(
return (
<AttributeForm
key={attributeId}
data={attribute}
handleDeleteAttribute={() => handleDeleteAttribute(attributeId)}
handleUpdateAttribute={async (data) =>
await handleUpdateAttribute(attributeId, data)
}
attributeDetails={attribute}
objectId={object.id ?? ""}
type={attribute.type}
/>

View File

@ -84,8 +84,9 @@ export const CUSTOM_ATTRIBUTES_LIST: {
},
},
multi_select: {
defaultFormValues: { default_value: "", display_name: "", is_required: false },
defaultFormValues: { default_value: "", display_name: "", is_multi: true, is_required: false },
icon: Disc,
initialPayload: { is_multi: true },
label: "Multi Select",
},
number: {

View File

@ -63,7 +63,10 @@ class CustomAttributeValuesStore {
newChildren.map((child) => {
if (attributesToUpdate.includes(child.id) && child)
child.prop_value = [{ type: 0, value: data.issue_properties[child.id] }];
child.prop_value = data.issue_properties[child.id].map((value) => ({
type: 0,
value,
}));
return child;
});

View File

@ -81,7 +81,7 @@ export interface ICustomAttributeValue {
export interface ICustomAttributeValueFormData {
issue_properties: {
[key: string]: string;
[key: string]: string[];
};
a_epoch?: number;
}