diff --git a/web/components/custom-attributes/attribute-forms/attribute-form.tsx b/web/components/custom-attributes/attribute-forms/attribute-form.tsx index ad709513a..92f380113 100644 --- a/web/components/custom-attributes/attribute-forms/attribute-form.tsx +++ b/web/components/custom-attributes/attribute-forms/attribute-form.tsx @@ -29,18 +29,53 @@ type Props = { data: Partial; handleDeleteAttribute: () => void; handleUpdateAttribute: (data: Partial) => Promise; + objectId: string; type: TCustomAttributeTypes; }; export type FormComponentProps = { control: Control, any>; - watch?: UseFormWatch>; + objectId: string; + watch: UseFormWatch>; +}; + +const RenderForm: React.FC<{ type: TCustomAttributeTypes } & FormComponentProps> = ({ + control, + objectId, + type, + watch, +}) => { + let FormToRender: any = <>; + + if (type === "checkbox") + FormToRender = ; + else if (type === "datetime") + FormToRender = ; + else if (type === "email") + FormToRender = ; + else if (type === "files") + FormToRender = ; + else if (type === "multi_select") + FormToRender = ; + else if (type === "number") + FormToRender = ; + else if (type === "relation") + FormToRender = ; + else if (type === "select") + FormToRender = ; + else if (type === "text") + FormToRender = ; + else if (type === "url") + FormToRender = ; + + return FormToRender; }; export const AttributeForm: React.FC = ({ data, handleDeleteAttribute, handleUpdateAttribute, + objectId, type, }) => { const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type]; @@ -57,25 +92,6 @@ export const AttributeForm: React.FC = ({ await handleUpdateAttribute(data); }; - const renderForm = (type: TCustomAttributeTypes): JSX.Element => { - let FormToRender = <>; - - if (type === "checkbox") FormToRender = ; - else if (type === "datetime") FormToRender = ; - else if (type === "email") FormToRender = ; - else if (type === "files") FormToRender = ; - else if (type === "multi_select") - FormToRender = ; - else if (type === "number") - FormToRender = ; - else if (type === "relation") FormToRender = ; - else if (type === "select") FormToRender = ; - else if (type === "text") FormToRender = ; - else if (type === "url") FormToRender = ; - - return FormToRender; - }; - useEffect(() => { if (!data) return; @@ -95,7 +111,7 @@ export const AttributeForm: React.FC = ({
-
{typeMetaData.label}
+
{data.display_name ?? typeMetaData.label}
@@ -103,7 +119,9 @@ export const AttributeForm: React.FC = ({
- {renderForm(type)} + {data.type && ( + + )}
= ({ > Remove - {isSubmitting ? "Saving..." : "Save"} + + {isSubmitting ? "Saving..." : "Save"} +
diff --git a/web/components/custom-attributes/attribute-forms/index.ts b/web/components/custom-attributes/attribute-forms/index.ts index 99c35044f..f419c6c79 100644 --- a/web/components/custom-attributes/attribute-forms/index.ts +++ b/web/components/custom-attributes/attribute-forms/index.ts @@ -1,3 +1,4 @@ +export * from "./select-attribute"; export * from "./attribute-form"; export * from "./checkbox-attribute-form"; export * from "./date-time-attribute-form"; @@ -5,6 +6,5 @@ export * from "./email-attribute-form"; export * from "./file-attribute-form"; export * from "./number-attribute-form"; export * from "./relation-attribute-form"; -export * from "./select-attribute-form"; export * from "./text-attribute-form"; export * from "./url-attribute-form"; diff --git a/web/components/custom-attributes/attribute-forms/select-attribute-form.tsx b/web/components/custom-attributes/attribute-forms/select-attribute-form.tsx deleted file mode 100644 index 5799edb0e..000000000 --- a/web/components/custom-attributes/attribute-forms/select-attribute-form.tsx +++ /dev/null @@ -1,105 +0,0 @@ -import React from "react"; - -// react-hook-form -import { Controller } from "react-hook-form"; -// react-color -import { TwitterPicker } from "react-color"; -// headless ui -import { Popover, Transition } from "@headlessui/react"; -// components -import { FormComponentProps, Input } from "components/custom-attributes"; -// icons -import { MoreHorizontal } from "lucide-react"; - -export const SelectOption: React.FC = () => ( -
-
- {/* */} -

- 🚀 Option 1 -

-
-
- - -
-
-); - -export const SelectAttributeForm: React.FC = ({ - control, - multiple = false, -}) => ( -
- ( - - )} - /> -
-

Options

-
- {/* TODO: map over options */} - - - -
-
-
- 🚀 - - - {({ open, close }) => ( - <> - - - - - - - ( - { - onChange(value.hex); - close(); - }} - /> - )} - /> - - - - )} - -
-
-
-
-); diff --git a/web/components/custom-attributes/attribute-forms/select-attribute/index.ts b/web/components/custom-attributes/attribute-forms/select-attribute/index.ts new file mode 100644 index 000000000..0e6f78a6a --- /dev/null +++ b/web/components/custom-attributes/attribute-forms/select-attribute/index.ts @@ -0,0 +1,3 @@ +export * from "./option-form"; +export * from "./select-attribute-form"; +export * from "./select-option"; diff --git a/web/components/custom-attributes/attribute-forms/select-attribute/option-form.tsx b/web/components/custom-attributes/attribute-forms/select-attribute/option-form.tsx new file mode 100644 index 000000000..472d53b46 --- /dev/null +++ b/web/components/custom-attributes/attribute-forms/select-attribute/option-form.tsx @@ -0,0 +1,107 @@ +import React, { 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 { Popover, Transition } from "@headlessui/react"; +// react-color +import { TwitterPicker } from "react-color"; +// ui +import { PrimaryButton } from "components/ui"; +import { ICustomAttribute } from "types"; + +type Props = { + objectId: string; + parentId: string; +}; + +export const OptionForm: React.FC = observer(({ objectId, parentId }) => { + const [optionName, setOptionName] = useState(""); + const [optionColor, setOptionColor] = useState("#000000"); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { customAttributes: customAttributesStore } = useMobxStore(); + const { createAttributeOption, createAttributeOptionLoader } = customAttributesStore; + + const handleCreateOption = async () => { + if (!workspaceSlug) return; + + if (!optionName || optionName === "") return; + + const payload: Partial = { + color: optionColor, + display_name: optionName, + type: "option", + }; + + await createAttributeOption(workspaceSlug.toString(), objectId, { + ...payload, + parent: parentId, + }).then(() => { + setOptionName(""); + setOptionColor("#000000"); + }); + }; + + return ( +
+
+ {/* 🚀 */} + setOptionName(e.target.value)} + placeholder="Enter new option" + /> + + {({ close }) => ( + <> + + + + + + + { + setOptionColor(value.hex); + close(); + }} + /> + + + + )} + +
+
+ + {createAttributeOptionLoader ? "Adding..." : "Add"} + +
+
+ ); +}); diff --git a/web/components/custom-attributes/attribute-forms/select-attribute/select-attribute-form.tsx b/web/components/custom-attributes/attribute-forms/select-attribute/select-attribute-form.tsx new file mode 100644 index 000000000..94504efb0 --- /dev/null +++ b/web/components/custom-attributes/attribute-forms/select-attribute/select-attribute-form.tsx @@ -0,0 +1,41 @@ +import React from "react"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// react-hook-form +import { Controller } from "react-hook-form"; +// components +import { FormComponentProps, Input, OptionForm, SelectOption } from "components/custom-attributes"; + +export const SelectAttributeForm: React.FC = observer( + ({ control, multiple = false, objectId = "", watch }) => { + const { customAttributes: customAttributesStore } = useMobxStore(); + const { entityAttributes } = customAttributesStore; + + const options = entityAttributes?.[objectId]?.[watch("id") ?? ""]?.children; + + return ( +
+ ( + + )} + /> +
+

Options

+
+ {options?.map((option) => ( + + ))} +
+
+ +
+
+
+ ); + } +); diff --git a/web/components/custom-attributes/attribute-forms/select-attribute/select-option.tsx b/web/components/custom-attributes/attribute-forms/select-attribute/select-option.tsx new file mode 100644 index 000000000..dd5bc6374 --- /dev/null +++ b/web/components/custom-attributes/attribute-forms/select-attribute/select-option.tsx @@ -0,0 +1,68 @@ +import { useRouter } from "next/router"; + +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// ui +import { Tooltip } from "components/ui"; +// icons +import { MoreHorizontal } from "lucide-react"; +// types +import { ICustomAttribute } from "types"; + +type Props = { + objectId: string; + option: ICustomAttribute; +}; + +export const SelectOption: React.FC = observer(({ objectId, option }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { customAttributes: customAttributesStore } = useMobxStore(); + const { updateAttributeOption } = customAttributesStore; + + const handleSetAsDefault = async () => { + if (!workspaceSlug || !option.parent) return; + + await updateAttributeOption(workspaceSlug.toString(), objectId, option.parent, option.id, { + is_default: true, + }); + }; + + return ( +
+
+ {/* */} + +

+ {option.display_name} +

+
+
+
+ {option.is_default ? ( + Default + ) : ( + + )} + +
+
+ ); +}); diff --git a/web/components/custom-attributes/attributes/index.ts b/web/components/custom-attributes/attributes/index.ts new file mode 100644 index 000000000..e20cd3f8a --- /dev/null +++ b/web/components/custom-attributes/attributes/index.ts @@ -0,0 +1 @@ +export * from "./text"; diff --git a/web/components/custom-attributes/attributes/text.tsx b/web/components/custom-attributes/attributes/text.tsx new file mode 100644 index 000000000..15c971de3 --- /dev/null +++ b/web/components/custom-attributes/attributes/text.tsx @@ -0,0 +1,26 @@ +// types +import { ICustomAttribute } from "types"; + +type Props = { + attributeDetails: ICustomAttribute; + issueId: string; + onChange: (value: string) => void; + projectId: string; + value: string; +}; + +export const CustomTextAttribute: React.FC = ({ + attributeDetails, + issueId, + onChange, + value, +}) => ( + onChange(e.target.value)} + value={value} + /> +); diff --git a/web/components/custom-attributes/index.ts b/web/components/custom-attributes/index.ts index 679082d31..27624b46d 100644 --- a/web/components/custom-attributes/index.ts +++ b/web/components/custom-attributes/index.ts @@ -1,4 +1,5 @@ export * from "./attribute-forms"; +export * from "./attributes"; export * from "./dropdowns"; export * from "./delete-object-modal"; export * from "./input"; diff --git a/web/components/custom-attributes/object-modal.tsx b/web/components/custom-attributes/object-modal.tsx index e2084086f..2661f4f1c 100644 --- a/web/components/custom-attributes/object-modal.tsx +++ b/web/components/custom-attributes/object-modal.tsx @@ -8,20 +8,7 @@ import { useMobxStore } from "lib/mobx/store-provider"; // headless ui import { Dialog, Transition } from "@headlessui/react"; // components -import { - TextAttributeForm, - Input, - TypesDropdown, - NumberAttributeForm, - CheckboxAttributeForm, - RelationAttributeForm, - DateTimeAttributeForm, - UrlAttributeForm, - EmailAttributeForm, - FileAttributeForm, - SelectAttributeForm, - AttributeForm, -} from "components/custom-attributes"; +import { Input, TypesDropdown, AttributeForm } from "components/custom-attributes"; // ui import { Loader, PrimaryButton, SecondaryButton } from "components/ui"; // types @@ -211,6 +198,7 @@ export const ObjectModal: React.FC = observer( handleUpdateAttribute={async (data) => await handleUpdateAttribute(attributeId, data) } + objectId={object.id ?? ""} type={attribute.type} /> ); diff --git a/web/components/issues/custom-attributes-list.tsx b/web/components/issues/custom-attributes-list.tsx new file mode 100644 index 000000000..132477406 --- /dev/null +++ b/web/components/issues/custom-attributes-list.tsx @@ -0,0 +1,64 @@ +import { useEffect } from "react"; + +import { useRouter } from "next/router"; + +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { observer } from "mobx-react-lite"; +import { Loader } from "components/ui"; +import { CustomTextAttribute } from "components/custom-attributes"; + +type Props = { + entityId: string; + issueId: string; + projectId: string; +}; + +export const CustomAttributesList: React.FC = observer( + ({ entityId, issueId, projectId }) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { customAttributes: customAttributesStore } = useMobxStore(); + const { entityAttributes, fetchEntityDetails, fetchEntityDetailsLoader } = + customAttributesStore; + + const attributes = entityAttributes[entityId] ?? {}; + + useEffect(() => { + if (!entityAttributes[entityId]) { + if (!workspaceSlug) return; + + fetchEntityDetails(workspaceSlug.toString(), entityId); + } + }, [entityAttributes, entityId, fetchEntityDetails, workspaceSlug]); + + return ( +
+ {fetchEntityDetailsLoader ? ( + + + + + + ) : ( +
+ {Object.entries(attributes).map(([attributeId, attribute]) => ( +
+ {attribute.type === "text" && ( + {}} + projectId={projectId} + value={attribute.default_value ?? ""} + /> + )} +
+ ))} +
+ )} +
+ ); + } +); diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index 48cf67bf8..2f8f353d9 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -10,7 +10,7 @@ import aiService from "services/ai.service"; import useToast from "hooks/use-toast"; // components import { GptAssistantModal } from "components/core"; -import { ParentIssuesListModal } from "components/issues"; +import { CustomAttributesList, ParentIssuesListModal } from "components/issues"; import { IssueAssigneeSelect, IssueDateSelect, @@ -416,7 +416,7 @@ export const IssueForm: FC = ({ /> )} {/* default object properties */} - {watch("entity") === null && ( + {watch("entity") === null ? ( <> {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( = ({ )} + ) : ( + )}
diff --git a/web/components/issues/index.ts b/web/components/issues/index.ts index d0ab71e1c..1f9b87c80 100644 --- a/web/components/issues/index.ts +++ b/web/components/issues/index.ts @@ -1,13 +1,15 @@ export * from "./attachment"; export * from "./comment"; +export * from "./gantt-chart"; export * from "./my-issues"; +export * from "./peek-overview"; export * from "./sidebar-select"; export * from "./view-select"; export * from "./activity"; +export * from "./custom-attributes-list"; export * from "./delete-issue-modal"; export * from "./description-form"; export * from "./form"; -export * from "./gantt-chart"; export * from "./main-content"; export * from "./modal"; export * from "./parent-issues-list-modal"; @@ -15,4 +17,3 @@ export * from "./sidebar"; export * from "./sub-issues-list"; export * from "./label"; export * from "./issue-reaction"; -export * from "./peek-overview"; diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/settings/custom-objects.tsx b/web/pages/[workspaceSlug]/projects/[projectId]/settings/custom-objects.tsx index 9dbf11dc6..4b17ff92d 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/settings/custom-objects.tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/settings/custom-objects.tsx @@ -8,7 +8,7 @@ import { ProjectAuthorizationWrapper } from "layouts/auth-layout"; import useToast from "hooks/use-toast"; import useProjectDetails from "hooks/use-project-details"; // components -import { SettingsHeader } from "components/project"; +import { SettingsSidebar } from "components/project"; import { ObjectModal, ObjectsList } from "components/custom-attributes"; // ui import { BreadcrumbItem, Breadcrumbs } from "components/breadcrumbs"; @@ -19,7 +19,7 @@ import { truncateText } from "helpers/string.helper"; import type { NextPage } from "next"; import { ICustomAttribute } from "types"; -const ControlSettings: NextPage = () => { +const CustomObjectSettings: NextPage = () => { const [isCreateObjectModalOpen, setIsCreateObjectModalOpen] = useState(false); const [objectToEdit, setObjectToEdit] = useState(null); @@ -56,25 +56,29 @@ const ControlSettings: NextPage = () => { setObjectToEdit(null); }} /> -
- -
-
-

Custom Objects

+
+
+ +
+
+
+

Custom Objects

setIsCreateObjectModalOpen(true)}> Add Object
-
- +
+
+ +
-
+
); }; -export default ControlSettings; +export default CustomObjectSettings; diff --git a/web/store/custom-attributes.ts b/web/store/custom-attributes.ts index 787d92040..6cab278ba 100644 --- a/web/store/custom-attributes.ts +++ b/web/store/custom-attributes.ts @@ -14,6 +14,7 @@ class CustomAttributesStore { fetchEntitiesLoader = false; fetchEntityDetailsLoader = false; createEntityAttributeLoader = false; + createAttributeOptionLoader = false; // errors attributesFetchError: any | null = null; error: any | null = null; @@ -30,6 +31,9 @@ class CustomAttributesStore { createEntityAttribute: action, updateEntityAttribute: action, deleteEntityAttribute: action, + createAttributeOption: action, + updateAttributeOption: action, + deleteAttributeOption: action, }); this.rootStore = _rootStore; @@ -196,6 +200,117 @@ class CustomAttributesStore { }); } }; + + createAttributeOption = async ( + workspaceSlug: string, + objectId: string, + data: Partial & { parent: string } + ) => { + try { + this.createAttributeOptionLoader = true; + + const response = await customAttributesService.createProperty(workspaceSlug, data); + + runInAction(() => { + this.entityAttributes = { + ...this.entityAttributes, + [objectId]: { + ...this.entityAttributes[objectId], + [data.parent]: { + ...this.entityAttributes[objectId][data.parent], + children: [...this.entityAttributes[objectId][data.parent].children, response], + }, + }, + }; + this.createAttributeOptionLoader = false; + }); + + return response; + } catch (error) { + runInAction(() => { + this.error = error; + this.createAttributeOptionLoader = false; + }); + } + }; + + updateAttributeOption = async ( + workspaceSlug: string, + objectId: string, + parentId: string, + propertyId: string, + data: Partial + ) => { + try { + this.createAttributeOptionLoader = true; + + const response = await customAttributesService.patchProperty(workspaceSlug, propertyId, data); + + const newOptions = this.entityAttributes[objectId][parentId].children.map((option) => ({ + ...option, + ...(option.id === propertyId ? response : {}), + })); + + runInAction(() => { + this.entityAttributes = { + ...this.entityAttributes, + [objectId]: { + ...this.entityAttributes[objectId], + [parentId]: { + ...this.entityAttributes[objectId][parentId], + children: newOptions, + }, + }, + }; + this.createAttributeOptionLoader = false; + }); + + return response; + } catch (error) { + runInAction(() => { + this.error = error; + this.createAttributeOptionLoader = false; + }); + } + }; + + deleteAttributeOption = async ( + workspaceSlug: string, + objectId: string, + parentId: string, + propertyId: string + ) => { + try { + this.createAttributeOptionLoader = true; + + const response = await customAttributesService.deleteProperty(workspaceSlug, propertyId); + + const newOptions = this.entityAttributes[objectId][parentId].children.filter( + (option) => option.id !== propertyId + ); + + runInAction(() => { + this.entityAttributes = { + ...this.entityAttributes, + [objectId]: { + ...this.entityAttributes[objectId], + [parentId]: { + ...this.entityAttributes[objectId][parentId], + children: newOptions, + }, + }, + }; + this.createAttributeOptionLoader = false; + }); + + return response; + } catch (error) { + runInAction(() => { + this.error = error; + this.createAttributeOptionLoader = false; + }); + } + }; } export default CustomAttributesStore; diff --git a/web/types/custom-attributes.d.ts b/web/types/custom-attributes.d.ts index b94bda241..f2f842adf 100644 --- a/web/types/custom-attributes.d.ts +++ b/web/types/custom-attributes.d.ts @@ -17,7 +17,7 @@ export type TCustomAttributeUnits = "cycle" | "issue" | "module" | "user"; export interface ICustomAttribute { children: ICustomAttribute[]; color: string; - default_value: string | null; + default_value: string; description: string; display_name: string; extra_settings: { [key: string]: any }; @@ -26,7 +26,7 @@ export interface ICustomAttribute { is_default: boolean; is_multi: boolean; is_required: boolean; - parent: string; + parent: string | null; project: string | null; sort_order: number; type: TCustomAttributeTypes;