From 4c016c85f1e82cdbd79a674dc1e5a795c8107143 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Wed, 20 Sep 2023 15:16:13 +0530 Subject: [PATCH] chore: file upload in the issue modal --- .../attribute-display/select.tsx | 177 ++++++-------- .../issue-modal/checkboxes.tsx | 72 ++---- .../issue-modal/description-fields.tsx | 33 +-- .../issue-modal/file-uploads.tsx | 225 ++++++++++++++++++ .../attributes-list/issue-modal/index.ts | 1 + .../issue-modal/select-fields.tsx | 23 +- .../dropdowns/types-dropdown.tsx | 98 ++++---- web/components/issues/form.tsx | 76 +++++- web/components/issues/modal.tsx | 14 +- 9 files changed, 468 insertions(+), 251 deletions(-) create mode 100644 web/components/custom-attributes/attributes-list/issue-modal/file-uploads.tsx diff --git a/web/components/custom-attributes/attribute-display/select.tsx b/web/components/custom-attributes/attribute-display/select.tsx index 53cd1ed2c..49a720726 100644 --- a/web/components/custom-attributes/attribute-display/select.tsx +++ b/web/components/custom-attributes/attribute-display/select.tsx @@ -1,9 +1,9 @@ import React, { useRef, useState } from "react"; // headless ui -import { Combobox, Transition } from "@headlessui/react"; +import { Combobox } from "@headlessui/react"; // hooks -import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; // icons import { Check, Search, XIcon } from "lucide-react"; // types @@ -36,46 +36,21 @@ export const CustomSelectAttribute: React.FC = (props) => { 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 = { onChange, value, }; + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownButtonRef, dropdownOptionsRef); + if (multiple) comboboxProps.multiple = true; return ( {({ open }: { open: boolean }) => { - if (open) handleOnOpen(); + if (open) { + if (!isOpen) setIsOpen(true); + } else if (isOpen) setIsOpen(false); return ( <> @@ -98,8 +73,7 @@ export const CustomSelectAttribute: React.FC = (props) => { {optionDetails?.display_name} {((attributeDetails.is_required && value.length > 1) || !attributeDetails.is_required) && ( - + )} ); @@ -122,28 +96,25 @@ export const CustomSelectAttribute: React.FC = (props) => { ) ) : ( -
-
o.id === value)?.color}40`, - }} - > - {options.find((o) => o.id === value)?.display_name} - {!attributeDetails.is_required && ( - - )} -
+ onChange(undefined); + }} + > + + + )}
) ) : ( @@ -154,58 +125,48 @@ export const CustomSelectAttribute: React.FC = (props) => { )} - -
- -
- - setQuery(e.target.value)} - placeholder="Type to search..." - displayValue={(assigned: any) => assigned?.name} - /> -
-
- {(options ?? []).map((option) => ( - - `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 }) => ( - <> - - {option.display_name} - - {selected && } - - )} - - ))} -
-
-
-
+
+ +
+ + setQuery(e.target.value)} + placeholder="Type to search..." + displayValue={(assigned: any) => assigned?.name} + /> +
+
+ {(options ?? []).map((option) => ( + + `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 }) => ( + <> + + {option.display_name} + + {selected && } + + )} + + ))} +
+
+
); }} diff --git a/web/components/custom-attributes/attributes-list/issue-modal/checkboxes.tsx b/web/components/custom-attributes/attributes-list/issue-modal/checkboxes.tsx index e9df1e837..0dc1c4a24 100644 --- a/web/components/custom-attributes/attributes-list/issue-modal/checkboxes.tsx +++ b/web/components/custom-attributes/attributes-list/issue-modal/checkboxes.tsx @@ -1,7 +1,3 @@ -import { useEffect } from "react"; - -import { useRouter } from "next/router"; - // mobx import { useMobxStore } from "lib/mobx/store-provider"; import { observer } from "mobx-react-lite"; @@ -21,58 +17,40 @@ type Props = { export const CustomAttributesCheckboxes: React.FC = observer((props) => { const { entityId, issueId, onChange, projectId, values } = props; - const router = useRouter(); - const { workspaceSlug } = router.query; + const { customAttributes } = useMobxStore(); - const { customAttributes: customAttributesStore } = useMobxStore(); - const { entityAttributes, fetchEntityDetails, fetchEntityDetailsLoader } = customAttributesStore; - - const attributes = entityAttributes[entityId] ?? {}; - - // fetch entity details - useEffect(() => { - if (!entityAttributes[entityId]) { - if (!workspaceSlug) return; - - fetchEntityDetails(workspaceSlug.toString(), entityId); - } - }, [entityAttributes, entityId, fetchEntityDetails, workspaceSlug]); + const attributes = customAttributes.entityAttributes[entityId] ?? {}; const checkboxFields = Object.values(attributes).filter((a) => a.type === "checkbox"); return ( <> - {fetchEntityDetailsLoader ? ( - - - - + {customAttributes.fetchEntityDetailsLoader ? ( + + + + ) : ( -
-
Checkboxes
-
- {Object.entries(checkboxFields).map(([attributeId, attribute]) => ( -
- -

- {attribute.display_name} -

-
-
- {attribute.type === "checkbox" && ( - onChange(attribute.id, [`${val}`])} - projectId={projectId} - value={values[attribute.id]?.[0] === "true" ? true : false} - /> - )} -
+
+ {Object.entries(checkboxFields).map(([attributeId, attribute]) => ( +
+ +

+ {attribute.display_name} +

+
+
+ onChange(attribute.id, [`${val}`])} + projectId={projectId} + value={values[attribute.id]?.[0] === "true" ? true : false} + />
- ))} -
+
+ ))}
)} diff --git a/web/components/custom-attributes/attributes-list/issue-modal/description-fields.tsx b/web/components/custom-attributes/attributes-list/issue-modal/description-fields.tsx index 50e97faa0..d65fb1c86 100644 --- a/web/components/custom-attributes/attributes-list/issue-modal/description-fields.tsx +++ b/web/components/custom-attributes/attributes-list/issue-modal/description-fields.tsx @@ -1,6 +1,4 @@ -import { useEffect, useState } from "react"; - -import { useRouter } from "next/router"; +import { useState } from "react"; // mobx import { useMobxStore } from "lib/mobx/store-provider"; @@ -25,26 +23,13 @@ type Props = { const DESCRIPTION_FIELDS: TCustomAttributeTypes[] = ["email", "number", "text", "url"]; export const CustomAttributesDescriptionFields: React.FC = observer((props) => { - const { entityId, issueId, onChange, projectId, values } = props; + const { entityId, onChange, values } = props; const [hideOptionalFields, setHideOptionalFields] = useState(false); - const router = useRouter(); - const { workspaceSlug } = router.query; + const { customAttributes } = useMobxStore(); - const { customAttributes: customAttributesStore } = useMobxStore(); - const { entityAttributes, fetchEntityDetails, fetchEntityDetailsLoader } = customAttributesStore; - - const attributes = entityAttributes[entityId] ?? {}; - - // fetch entity details - useEffect(() => { - if (!entityAttributes[entityId]) { - if (!workspaceSlug) return; - - fetchEntityDetails(workspaceSlug.toString(), entityId); - } - }, [entityAttributes, entityId, fetchEntityDetails, workspaceSlug]); + const attributes = customAttributes.entityAttributes[entityId] ?? {}; const descriptionFields = Object.values(attributes).filter((a) => DESCRIPTION_FIELDS.includes(a.type) @@ -52,11 +37,11 @@ export const CustomAttributesDescriptionFields: React.FC = observer((prop return ( <> - {fetchEntityDetailsLoader ? ( - - - - + {customAttributes.fetchEntityDetailsLoader ? ( + + + + ) : ( diff --git a/web/components/custom-attributes/attributes-list/issue-modal/file-uploads.tsx b/web/components/custom-attributes/attributes-list/issue-modal/file-uploads.tsx new file mode 100644 index 000000000..e533ec74d --- /dev/null +++ b/web/components/custom-attributes/attributes-list/issue-modal/file-uploads.tsx @@ -0,0 +1,225 @@ +import { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +// mobx +import { useMobxStore } from "lib/mobx/store-provider"; +import { observer } from "mobx-react-lite"; +// react-dropzone +import { useDropzone } from "react-dropzone"; +// services +import fileService from "services/file.service"; +// hooks +import useWorkspaceDetails from "hooks/use-workspace-details"; +import useToast from "hooks/use-toast"; +// components +// ui +import { Loader, Tooltip } from "components/ui"; +// icons +import { Plus, Trash2, X } from "lucide-react"; +import { getFileIcon } from "components/icons"; +// helpers +import { getFileExtension } from "helpers/attachment.helper"; +// types +import { ICustomAttribute } from "types"; + +type Props = { + entityId: string; + issueId: string; + onChange: (attributeId: string, val: string | string[] | undefined) => void; + projectId: string; + values: { [key: string]: string[] }; +}; + +type FileUploadProps = { + attributeDetails: ICustomAttribute; + className?: string; + issueId: string; + projectId: string; + value: string | undefined; + onChange: (val: string | undefined) => void; +}; + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5 MB + +const UploadFile: React.FC = (props) => { + const { attributeDetails, className = "", onChange, value } = props; + + const [isUploading, setIsUploading] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { workspaceDetails } = useWorkspaceDetails(); + + const { setToastAlert } = useToast(); + + const onDrop = useCallback( + (acceptedFiles: File[]) => { + if (!acceptedFiles[0] || !workspaceSlug) return; + + const extension = getFileExtension(acceptedFiles[0].name); + + if (!attributeDetails.extra_settings?.file_formats?.includes(`.${extension}`)) { + setToastAlert({ + type: "error", + title: "Error!", + message: `File format not accepted. Accepted file formats- ${attributeDetails.extra_settings?.file_formats?.join( + ", " + )}`, + }); + return; + } + + const formData = new FormData(); + formData.append("asset", acceptedFiles[0]); + formData.append( + "attributes", + JSON.stringify({ + name: acceptedFiles[0].name, + size: acceptedFiles[0].size, + }) + ); + setIsUploading(true); + + fileService + .uploadFile(workspaceSlug.toString(), formData) + .then((res) => { + const imageUrl = res.asset; + + onChange(imageUrl); + + if (value && value !== "" && workspaceDetails) + fileService.deleteFile(workspaceDetails.id, value); + }) + .finally(() => setIsUploading(false)); + }, + [ + attributeDetails.extra_settings?.file_formats, + onChange, + setToastAlert, + value, + workspaceDetails, + workspaceSlug, + ] + ); + + const handleRemoveFile = () => { + if (!workspaceDetails || !value || value === "") return; + + onChange(undefined); + fileService.deleteFile(workspaceDetails.id, value); + }; + + const { getRootProps, getInputProps, isDragActive, isDragReject, fileRejections } = useDropzone({ + onDrop, + maxSize: MAX_FILE_SIZE, + multiple: false, + disabled: isUploading, + }); + + const fileError = + fileRejections.length > 0 + ? `Invalid file type or size (max ${MAX_FILE_SIZE / 1024 / 1024} MB)` + : null; + + return ( +
+ {value && value !== "" ? ( + + ) : ( +
+ +
+ + + + {isDragActive ? ( + Drop here + ) : fileError ? ( + File error + ) : isUploading ? ( + Uploading... + ) : ( + Drag and drop files + )} +
+
+ )} +
+ ); +}; + +export const CustomAttributesFileUploads: React.FC = observer((props) => { + const { entityId, onChange, issueId, projectId, values } = props; + + const { customAttributes } = useMobxStore(); + + const attributes = customAttributes.entityAttributes[entityId] ?? {}; + + const fileUploadFields = Object.values(attributes).filter((a) => a.type === "file"); + + return ( + <> + {customAttributes.fetchEntityDetailsLoader ? ( + + + + + + ) : ( +
+
File uploads
+
+ {Object.entries(fileUploadFields).map(([attributeId, attribute]) => ( +
+ +

{attribute.display_name}

+
+
+ onChange(attribute.id, val)} + projectId={projectId} + value={values[attribute.id]?.[0] ? values[attribute.id]?.[0] : undefined} + /> +
+
+ ))} +
+
+ )} + + ); +}); diff --git a/web/components/custom-attributes/attributes-list/issue-modal/index.ts b/web/components/custom-attributes/attributes-list/issue-modal/index.ts index 6bc0f35a4..fc219a055 100644 --- a/web/components/custom-attributes/attributes-list/issue-modal/index.ts +++ b/web/components/custom-attributes/attributes-list/issue-modal/index.ts @@ -1,3 +1,4 @@ export * from "./checkboxes"; export * from "./description-fields"; +export * from "./file-uploads"; export * from "./select-fields"; diff --git a/web/components/custom-attributes/attributes-list/issue-modal/select-fields.tsx b/web/components/custom-attributes/attributes-list/issue-modal/select-fields.tsx index 18f58f911..ed363d64f 100644 --- a/web/components/custom-attributes/attributes-list/issue-modal/select-fields.tsx +++ b/web/components/custom-attributes/attributes-list/issue-modal/select-fields.tsx @@ -1,7 +1,3 @@ -import { useEffect } from "react"; - -import { useRouter } from "next/router"; - // mobx import { useMobxStore } from "lib/mobx/store-provider"; import { observer } from "mobx-react-lite"; @@ -30,28 +26,15 @@ const SELECT_FIELDS: TCustomAttributeTypes[] = ["datetime", "multi_select", "rel export const CustomAttributesSelectFields: React.FC = observer((props) => { const { entityId, issueId, onChange, projectId, values } = props; - const router = useRouter(); - const { workspaceSlug } = router.query; + const { customAttributes } = useMobxStore(); - const { customAttributes: customAttributesStore } = useMobxStore(); - const { entityAttributes, fetchEntityDetails, fetchEntityDetailsLoader } = customAttributesStore; - - const attributes = entityAttributes[entityId] ?? {}; - - // fetch entity details - useEffect(() => { - if (!entityAttributes[entityId]) { - if (!workspaceSlug) return; - - fetchEntityDetails(workspaceSlug.toString(), entityId); - } - }, [entityAttributes, entityId, fetchEntityDetails, workspaceSlug]); + const attributes = customAttributes.entityAttributes[entityId] ?? {}; const selectFields = Object.values(attributes).filter((a) => SELECT_FIELDS.includes(a.type)); return ( <> - {fetchEntityDetailsLoader ? ( + {customAttributes.fetchEntityDetailsLoader ? ( diff --git a/web/components/custom-attributes/dropdowns/types-dropdown.tsx b/web/components/custom-attributes/dropdowns/types-dropdown.tsx index bf2ed3ea0..c04fa8832 100644 --- a/web/components/custom-attributes/dropdowns/types-dropdown.tsx +++ b/web/components/custom-attributes/dropdowns/types-dropdown.tsx @@ -1,7 +1,9 @@ -import React from "react"; +import React, { useRef, useState } from "react"; // headless ui -import { Menu, Transition } from "@headlessui/react"; +import { Menu } from "@headlessui/react"; +// hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; // icons import { Plus } from "lucide-react"; // types @@ -13,44 +15,56 @@ type Props = { onClick: (type: TCustomAttributeTypes) => void; }; -export const TypesDropdown: React.FC = ({ onClick }) => ( - - {({ open }: { open: boolean }) => ( - <> - - - Add Attribute - - - - {Object.keys(CUSTOM_ATTRIBUTES_LIST).map((type) => { - const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type as TCustomAttributeTypes]; +export const TypesDropdown: React.FC = ({ onClick }) => { + const [isOpen, setIsOpen] = useState(false); - return ( - onClick(type as TCustomAttributeTypes)} - className="flex items-center gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-80 w-full" - > - - {typeMetaData.label} - - ); - })} - - - - )} - -); + const buttonRef = useRef(null); + const optionsRef = useRef(null); + + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, optionsRef); + + return ( + + {({ open }: { open: boolean }) => { + if (open) { + if (!isOpen) setIsOpen(true); + } else if (isOpen) setIsOpen(false); + + return ( + <> + + + Add Attribute + +
+ + {Object.keys(CUSTOM_ATTRIBUTES_LIST).map((type) => { + const typeMetaData = CUSTOM_ATTRIBUTES_LIST[type as TCustomAttributeTypes]; + + return ( + onClick(type as TCustomAttributeTypes)} + className="flex items-center gap-1 cursor-pointer select-none truncate rounded px-1 py-1.5 hover:bg-custom-background-80 w-full" + > + + {typeMetaData.label} + + ); + })} + +
+ + ); + }} +
+ ); +}; diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index f51c80f88..ea06ecb3f 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -4,6 +4,7 @@ import { useRouter } from "next/router"; // mobx import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; // react-hook-form import { Controller, useForm } from "react-hook-form"; // services @@ -27,6 +28,7 @@ import { CreateLabelModal } from "components/labels"; import { CustomAttributesCheckboxes, CustomAttributesDescriptionFields, + CustomAttributesFileUploads, CustomAttributesSelectFields, ObjectsSelect, } from "components/custom-attributes"; @@ -103,6 +105,8 @@ export const IssueForm: FC = observer((props) => { const { setToastAlert } = useToast(); + const { customAttributes, customAttributeValues } = useMobxStore(); + const { register, formState: { errors, isSubmitting, isDirty }, @@ -133,6 +137,8 @@ export const IssueForm: FC = observer((props) => { parent: getValues("parent"), }; + const entityId = watch("entity"); + useEffect(() => { if (isDirty) handleFormDirty(payload); else handleFormDirty(null); @@ -234,6 +240,59 @@ export const IssueForm: FC = observer((props) => { const maxDate = targetDate ? new Date(targetDate) : null; maxDate?.setDate(maxDate.getDate()); + // fetch entity/object details, including the list of attributes + useEffect(() => { + if (!entityId) return; + + if (!customAttributes.entityAttributes[entityId]) { + if (!workspaceSlug) return; + + customAttributes.fetchEntityDetails(workspaceSlug.toString(), entityId); + } + }, [customAttributes, entityId, workspaceSlug]); + + // fetch issue attribute values + useEffect(() => { + if (!initialData || !initialData.id) return; + + if ( + !customAttributeValues.issueAttributeValues || + !customAttributeValues.issueAttributeValues[initialData.id] + ) { + if (!workspaceSlug) return; + + customAttributeValues + .fetchIssueAttributeValues(workspaceSlug.toString(), projectId, initialData.id) + .then(() => { + const issueAttributeValues = + customAttributeValues.issueAttributeValues?.[initialData.id ?? ""]; + + if (!issueAttributeValues || issueAttributeValues.length === 0) return; + + issueAttributeValues.forEach((attributeValue) => { + if (attributeValue.prop_value) + handleCustomAttributesChange( + attributeValue.id, + attributeValue.prop_value.map((val) => val.value) + ); + }); + }); + } else { + const issueAttributeValues = + customAttributeValues.issueAttributeValues?.[initialData.id ?? ""]; + + if (!issueAttributeValues || issueAttributeValues.length === 0) return; + + issueAttributeValues.forEach((attributeValue) => { + if (attributeValue.prop_value) + handleCustomAttributesChange( + attributeValue.id, + attributeValue.prop_value.map((val) => val.value) + ); + }); + } + }, [customAttributeValues, handleCustomAttributesChange, initialData, projectId, workspaceSlug]); + return ( <> {projectId && ( @@ -414,17 +473,24 @@ export const IssueForm: FC = observer((props) => { />
)} - {watch("entity") !== null && ( + {entityId !== null && (
+ = observer((props) => { /> )} {/* default object properties */} - {watch("entity") === null ? ( + {entityId === null ? ( <> {(fieldsToShow.includes("all") || fieldsToShow.includes("priority")) && ( = observer((props) => { ) : ( = observer( const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query; - const { customAttributeValues: customAttributeValuesStore } = useMobxStore(); - const { createAttributeValue } = customAttributeValuesStore; + const { customAttributeValues } = useMobxStore(); const { displayFilters, params } = useIssuesView(); const { params: calendarParams } = useCalendarIssuesView(); @@ -452,9 +451,14 @@ export const CreateUpdateIssueModal: React.FC = observer( issueResponse.id && Object.keys(customAttributesList).length > 0 ) - await createAttributeValue(workspaceSlug.toString(), activeProject, issueResponse.id, { - issue_properties: customAttributesList, - }); + await customAttributeValues.createAttributeValue( + workspaceSlug.toString(), + activeProject, + issueResponse.id, + { + issue_properties: customAttributesList, + } + ); if (onSubmit) await onSubmit(payload); };