From 1b032b06a63ce8b4e061162722fd80e8494db5ea Mon Sep 17 00:00:00 2001 From: anamontiaga Date: Thu, 31 Aug 2023 13:46:31 +0200 Subject: [PATCH] delete bulk cost surfaces --- .../components/inventory-table/index.tsx | 2 +- .../project/inventory-panel/constants.ts | 4 +- .../cost-surfaces/bulk-action-menu/index.tsx | 64 +++ .../cost-surfaces/bulk-action-menu/utils.ts | 75 +++ .../{cost-surface => cost-surfaces}/index.tsx | 15 +- .../info/index.tsx | 0 .../cost-surfaces/modals/delete/index.tsx | 131 +++++ .../cost-surfaces/modals/edit/index.tsx | 276 ++++++++++ .../cost-surfaces/modals/upload/index.tsx | 502 ++++++++++++++++++ 9 files changed, 1060 insertions(+), 9 deletions(-) create mode 100644 app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/index.tsx create mode 100644 app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts rename app/layout/project/sidebar/project/inventory-panel/{cost-surface => cost-surfaces}/index.tsx (92%) rename app/layout/project/sidebar/project/inventory-panel/{cost-surface => cost-surfaces}/info/index.tsx (100%) create mode 100644 app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index.tsx create mode 100644 app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx create mode 100644 app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx diff --git a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx index 113f29e011..92ca24fa0e 100644 --- a/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/components/inventory-table/index.tsx @@ -49,7 +49,7 @@ const InventoryTable = ({ diff --git a/app/layout/project/sidebar/project/inventory-panel/constants.ts b/app/layout/project/sidebar/project/inventory-panel/constants.ts index 7acacc1f3d..1e730c61c8 100644 --- a/app/layout/project/sidebar/project/inventory-panel/constants.ts +++ b/app/layout/project/sidebar/project/inventory-panel/constants.ts @@ -1,7 +1,7 @@ import { NavigationInventoryTabs } from 'layout/project/navigation/types'; -import CostSurfaceTable from './cost-surface'; -import CostSurfaceInfo from './cost-surface/info'; +import CostSurfaceTable from './cost-surfaces'; +import CostSurfaceInfo from './cost-surfaces/info'; import FeaturesTable from './features'; import FeaturesInfo from './features/info'; import FeatureUploadModal from './features/modals/upload'; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/index.tsx new file mode 100644 index 0000000000..4335d77d22 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/index.tsx @@ -0,0 +1,64 @@ +import { useCallback, useState } from 'react'; + +import Button from 'components/button'; +import Icon from 'components/icon'; +import Modal from 'components/modal/component'; +import DeleteModal from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index'; +import { CostSurface } from 'types/api/cost-surface'; + +import EDIT_SVG from 'svgs/ui/edit.svg?sprite'; +import DELETE_SVG from 'svgs/ui/new-layout/delete.svg?sprite'; + +const BUTTON_CLASSES = + 'col-span-1 flex items-center space-x-2 rounded-lg bg-gray-700 px-4 text-xs text-gray-50'; +const ICON_CLASSES = 'h-5 w-5 transition-colors text-gray-400 group-hover:text-gray-50'; + +const CostSurfaceBulkActionMenu = ({ + selectedCostSurfacesIds, +}: { + selectedCostSurfacesIds: CostSurface['id'][]; +}): JSX.Element => { + const [modalState, setModalState] = useState<{ edit: boolean; delete: boolean }>({ + edit: false, + delete: false, + }); + + const handleModal = useCallback((modalKey: keyof typeof modalState, isVisible: boolean) => { + setModalState((prevState) => ({ ...prevState, [modalKey]: isVisible })); + }, []); + + return ( + <> +
+ + + {selectedCostSurfacesIds.length} + + Selected + + + +
+ + handleModal('delete', false)} + > + + + + ); +}; + +export default CostSurfaceBulkActionMenu; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts new file mode 100644 index 0000000000..e0cba8e243 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu/utils.ts @@ -0,0 +1,75 @@ +import { Session } from 'next-auth'; + +import { Feature } from 'types/api/feature'; +import { Project } from 'types/api/project'; + +import PROJECTS from 'services/projects'; + +export function bulkDeleteFeatureFromProject( + pid: Project['id'], + fids: Feature['id'][], + session: Session +) { + const deleteFeatureFromProject = ({ pid, fid }: { pid: Project['id']; fid: Feature['id'] }) => { + return PROJECTS.delete(`/${pid}/features/${fid}`, { + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + }); + }; + + return Promise.all(fids.map((fid) => deleteFeatureFromProject({ pid, fid }))); +} + +export function editFeaturesTagsBulk( + projectId: Project['id'], + featureIds: Feature['id'][], + session: Session, + data: { + tagName: string; + } +) { + const editFeatureTag = ({ + featureId, + projectId, + data, + }: { + featureId: Feature['id']; + projectId: Project['id']; + data: { + tagName: string; + }; + }) => { + return PROJECTS.request({ + method: 'PATCH', + url: `/${projectId}/features/${featureId}/tags`, + data, + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + }); + }; + return Promise.all(featureIds.map((featureId) => editFeatureTag({ projectId, featureId, data }))); +} + +export function deleteFeaturesTagsBulk( + projectId: Project['id'], + featureIds: Feature['id'][], + session: Session +) { + const deleteFeatureTags = ({ + projectId, + featureId, + }: { + projectId: Project['id']; + featureId: Feature['id']; + }) => { + return PROJECTS.delete(`/${projectId}/features/${featureId}/tags`, { + headers: { + Authorization: `Bearer ${session.accessToken}`, + }, + }); + }; + + return Promise.all(featureIds.map((featureId) => deleteFeatureTags({ projectId, featureId }))); +} diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx similarity index 92% rename from app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx index d729ed87f6..ccd27241ba 100644 --- a/app/layout/project/sidebar/project/inventory-panel/cost-surface/index.tsx +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/index.tsx @@ -7,15 +7,18 @@ import { setSelectedCostSurfaces as setVisibleCostSurfaces } from 'store/slices/ import { useProjectCostSurfaces } from 'hooks/cost-surface'; +import CostSurfacesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/cost-surfaces/bulk-action-menu'; import ActionsMenu from 'layout/project/sidebar/project/inventory-panel/features/actions-menu'; -import FeaturesBulkActionMenu from 'layout/project/sidebar/project/inventory-panel/features/bulk-action-menu'; import { CostSurface } from 'types/api/cost-surface'; import InventoryTable, { type DataItem } from '../components/inventory-table'; -const COST_SURFACE_TABLE_COLUMNS = { - name: 'Name', -}; +const COST_SURFACE_TABLE_COLUMNS = [ + { + name: 'name', + text: 'Name', + }, +]; const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string }): JSX.Element => { const dispatch = useAppDispatch(); @@ -28,7 +31,7 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } const [selectedCostSurfaceIds, setSelectedCostSurfaceIds] = useState([]); const [filters, setFilters] = useState[1]>({ - sort: COST_SURFACE_TABLE_COLUMNS.name, + sort: COST_SURFACE_TABLE_COLUMNS[0].name, }); const allProjectCostSurfacesQuery = useProjectCostSurfaces( @@ -126,7 +129,7 @@ const InventoryPanelCostSurface = ({ noData: noDataMessage }: { noData: string } ActionsComponent={ActionsMenu} /> {displayBulkActions && ( - + )} ); diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx similarity index 100% rename from app/layout/project/sidebar/project/inventory-panel/cost-surface/info/index.tsx rename to app/layout/project/sidebar/project/inventory-panel/cost-surfaces/info/index.tsx diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index.tsx new file mode 100644 index 0000000000..0c242180f0 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/delete/index.tsx @@ -0,0 +1,131 @@ +import { useCallback, useMemo } from 'react'; + +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { useSession } from 'next-auth/react'; + +import { useProjectCostSurfaces } from 'hooks/cost-surface'; +import { useToasts } from 'hooks/toast'; + +import { Button } from 'components/button/component'; +import Icon from 'components/icon/component'; +import { ModalProps } from 'components/modal'; +import { bulkDeleteFeatureFromProject } from 'layout/project/sidebar/project/inventory-panel/features/bulk-action-menu/utils'; +import { CostSurface } from 'types/api/cost-surface'; +import { Pagination } from 'types/api/meta'; + +import ALERT_SVG from 'svgs/ui/new-layout/alert.svg?sprite'; + +const DeleteModal = ({ + selectedCostSurfacesIds, + onDismiss, +}: { + selectedCostSurfacesIds: CostSurface['id'][]; + onDismiss?: ModalProps['onDismiss']; +}): JSX.Element => { + const { data: session } = useSession(); + const queryClient = useQueryClient(); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + const { addToast } = useToasts(); + + const allProjectCostSurfacesQuery = useProjectCostSurfaces(pid, {}); + + const selectedCostSurfaces = useMemo(() => { + return allProjectCostSurfacesQuery.data?.filter(({ id }) => + selectedCostSurfacesIds.includes(id) + ); + }, [allProjectCostSurfacesQuery.data, selectedCostSurfacesIds]); + + const costSurfaceNames = selectedCostSurfaces.map(({ name }) => name); + // ? the user will be able to delete the features only if they are not being used by any scenario. + const haveScenarioAssociated = selectedCostSurfaces.some(({ scenarioUsageCount }) => + Boolean(scenarioUsageCount) + ); + + const handleBulkDelete = useCallback(() => { + const deletableFeatureIds = selectedCostSurfaces.map(({ id }) => id); + + bulkDeleteFeatureFromProject(pid, deletableFeatureIds, session) + .then(async () => { + await queryClient.invalidateQueries(['cost-surfaces', pid]); + + onDismiss(); + + addToast( + 'delete-bulk-project-cost-surfaces', + <> +

Success

+

The features were deleted successfully.

+ , + { + level: 'success', + } + ); + }) + .catch(() => { + addToast( + 'delete-bulk-project-cost-surfaces', + <> +

Error!

+

Something went wrong deleting the cost surfaces.

+ , + { + level: 'error', + } + ); + }); + }, [selectedCostSurfaces, addToast, onDismiss, pid, queryClient, session]); + + return ( +
+

{`Delete cost surface${ + selectedCostSurfacesIds.length > 1 ? 's' : '' + }`}

+

+ {selectedCostSurfacesIds.length > 1 ? ( +

+ + Are you sure you want to delete the following cost surfaces?
+ This action cannot be undone. +
+
    + {costSurfaceNames.map((name) => ( +
  • {name}
  • + ))} +
+
+ ) : ( + + Are you sure you want to delete "{costSurfaceNames[0]}" cost surface?
+ This action cannot be undone. +
+ )} +

+
+ +

+ A cost surface can be deleted ONLY if it's not being used by any scenario +

+
+
+ + +
+
+ ); +}; + +export default DeleteModal; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx new file mode 100644 index 0000000000..2596baaf40 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/edit/index.tsx @@ -0,0 +1,276 @@ +import React, { + ElementRef, + useCallback, + useRef, + InputHTMLAttributes, + useState, + useEffect, +} from 'react'; + +import { Form as FormRFF, Field as FieldRFF, FormProps } from 'react-final-form'; +import { useQueryClient } from 'react-query'; + +import { useRouter } from 'next/router'; + +import { + useEditFeatureTag, + useEditFeature, + useProjectFeatures, + useDeleteFeatureTag, +} from 'hooks/features'; +import { useProjectTags } from 'hooks/projects'; +import { useToasts } from 'hooks/toast'; + +import Button from 'components/button'; +import Field from 'components/forms/field'; +import Label from 'components/forms/label'; +import { composeValidators } from 'components/forms/validations'; +import Icon from 'components/icon/component'; +import { Feature } from 'types/api/feature'; + +import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; + +export type FormValues = { featureClassName: Feature['featureClassName']; tag: Feature['tag'] }; + +const EditModal = ({ + featureId, + handleModal, +}: { + featureId: Feature['id']; + handleModal: (modalKey: 'delete' | 'edit', isVisible: boolean) => void; +}): JSX.Element => { + const queryClient = useQueryClient(); + const { addToast } = useToasts(); + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const formRef = useRef['form']>(null); + const tagsSectionRef = useRef>(null); + + const [tagsMenuOpen, setTagsMenuOpen] = useState(false); + const [tagIsDone, setTagIsDone] = useState(false); + + const tagsQuery = useProjectTags(pid); + const featureQuery = useProjectFeatures(pid, featureId); + const editFeatureTagMutation = useEditFeatureTag(); + const deleteFeatureTagMutation = useDeleteFeatureTag(); + const editFeatureMutation = useEditFeature(); + + useEffect(() => { + const handleClickOutside = (event) => { + if (tagsSectionRef.current && !tagsSectionRef.current.contains(event.target)) { + setTagsMenuOpen(false); + } + }; + document.addEventListener('click', handleClickOutside, true); + return () => { + document.removeEventListener('click', handleClickOutside, true); + }; + }, []); + + const onEditSubmit = useCallback( + (values: FormValues) => { + const { featureClassName, tag } = values; + const editFeaturePromise = editFeatureMutation.mutateAsync({ + fid: featureId, + body: { + featureClassName, + }, + }); + + const editFeatureTagPromise = () => { + if (values.tag) { + return editFeatureTagMutation.mutateAsync({ + projectId: pid, + featureId, + data: { + tagName: tag, + }, + }); + } else { + return deleteFeatureTagMutation.mutateAsync({ + projectId: pid, + featureId, + }); + } + }; + + Promise.all([editFeaturePromise, editFeatureTagPromise()]) + .then(async () => { + await queryClient.invalidateQueries(['all-features', pid]); + handleModal('edit', false); + + addToast( + 'success-edit-feature', + <> +

Success!

+

Features edited

+ , + { + level: 'success', + } + ); + }) + .catch(() => { + addToast( + 'error-edit-feature', + <> +

Error!

+

It is not possible to edit this feature

+ , + { + level: 'error', + } + ); + }); + }, + [ + addToast, + deleteFeatureTagMutation, + editFeatureTagMutation, + editFeatureMutation, + featureId, + handleModal, + pid, + queryClient, + ] + ); + + const handleKeyPress = useCallback( + (event: Parameters['onKeyDown']>[0]) => { + if (event.key === 'Enter') { + setTagIsDone(true); + formRef.current.change('tag', event.currentTarget.value); + setTagsMenuOpen(false); + } + }, + [formRef] + ); + + return ( + + initialValues={{ + featureClassName: featureQuery.data?.[0]?.featureClassName, + tag: featureQuery.data?.[0]?.tag, + }} + ref={formRef} + onSubmit={onEditSubmit} + render={({ form, handleSubmit, values }) => { + formRef.current = form; + + return ( +
+
+

Edit feature

+ +
+ + name="featureClassName" + validate={composeValidators([{ presence: true }])} + > + {(fprops) => ( + + + + + + )} + +
+ +
+ name="tag"> + {(fprops) => ( + + + {(!values.tag || !tagIsDone) && ( +
+ { + setTagsMenuOpen(true); + form.change('tag', ''); + }} + onBlur={() => setTagIsDone(true)} + onKeyDown={handleKeyPress} + /> + + {tagsMenuOpen && ( +
+
Recent:
+
+ {tagsQuery.data?.map((tag) => ( + + ))} +
+
+ )} +
+ )} + + {values.tag && tagIsDone && ( +
+
+

{values.tag}

+
+ +
+ )} +
+ )} + +
+ +
+ + + +
+
+
+ ); + }} + /> + ); +}; + +export default EditModal; diff --git a/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx new file mode 100644 index 0000000000..af00b70443 --- /dev/null +++ b/app/layout/project/sidebar/project/inventory-panel/cost-surfaces/modals/upload/index.tsx @@ -0,0 +1,502 @@ +import React, { + ElementRef, + InputHTMLAttributes, + useCallback, + useEffect, + useRef, + useState, +} from 'react'; + +import { useDropzone, DropzoneProps } from 'react-dropzone'; +import { Form as FormRFF, Field as FieldRFF, FormProps } from 'react-final-form'; + +import { useRouter } from 'next/router'; + +import { AxiosError, isAxiosError } from 'axios'; +import { motion } from 'framer-motion'; + +import { useUploadFeaturesCSV, useUploadFeaturesShapefile } from 'hooks/features'; +import { useDownloadShapefileTemplate } from 'hooks/projects'; +import { useProjectTags } from 'hooks/projects'; +import { useToasts } from 'hooks/toast'; + +import Button from 'components/button'; +import Field from 'components/forms/field'; +import Input from 'components/forms/input'; +import Label from 'components/forms/label'; +import { composeValidators } from 'components/forms/validations'; +import Icon from 'components/icon'; +import InfoButton from 'components/info-button'; +import Loading from 'components/loading'; +import Modal from 'components/modal'; +import UploadTabs from 'components/upload-tabs'; +import { + FEATURES_UPLOADER_SHAPEFILE_MAX_SIZE, + FEATURES_UPLOADER_CSV_MAX_SIZE, +} from 'constants/file-uploader-size-limits'; +import UploadFeaturesInfoButtonContent from 'constants/info-button-content/upload-features'; +import { Feature } from 'types/api/feature'; +import { cn } from 'utils/cn'; +import { bytesToMegabytes } from 'utils/units'; + +import CLOSE_SVG from 'svgs/ui/close.svg?sprite'; + +export type FormValues = { + name: string; + file: File; + tag: Feature['tag']; +}; + +export const FeatureUploadModal = ({ + isOpen = false, + onDismiss, +}: { + isOpen?: boolean; + onDismiss: () => void; +}): JSX.Element => { + const formRef = useRef['form']>(null); + const tagsSectionRef = useRef>(null); + + const [loading, setLoading] = useState(false); + const [successFile, setSuccessFile] = useState<{ name: FormValues['name'] }>(null); + const [uploadMode, saveUploadMode] = useState<'shapefile' | 'csv'>('shapefile'); + const [tagsMenuOpen, setTagsMenuOpen] = useState(false); + const [tagIsDone, setTagIsDone] = useState(false); + + const { query } = useRouter(); + const { pid } = query as { pid: string }; + + const { addToast } = useToasts(); + + const tagsQuery = useProjectTags(pid); + + const uploadFeaturesShapefileMutation = useUploadFeaturesShapefile({ + requestConfig: { + method: 'POST', + }, + }); + + const uploadFeaturesCSVMutation = useUploadFeaturesCSV({}); + + const downloadShapefileTemplateMutation = useDownloadShapefileTemplate(); + + const UPLOADER_MAX_SIZE = + uploadMode === 'shapefile' + ? FEATURES_UPLOADER_SHAPEFILE_MAX_SIZE + : FEATURES_UPLOADER_CSV_MAX_SIZE; + + useEffect(() => { + const handleClickOutside = (event) => { + if (tagsSectionRef.current && !tagsSectionRef.current.contains(event.target)) { + setTagsMenuOpen(false); + } + }; + document.addEventListener('click', handleClickOutside, true); + return () => { + document.removeEventListener('click', handleClickOutside, true); + }; + }, []); + + useEffect(() => { + return () => { + setSuccessFile(null); + }; + }, []); + + const onClose = useCallback(() => { + onDismiss(); + setSuccessFile(null); + }, [onDismiss]); + + const onDropAccepted = (acceptedFiles: Parameters[0]) => { + const f = acceptedFiles[0]; + setSuccessFile({ name: f.name }); + + formRef.current.change('file', f); + }; + + const onDropRejected = (rejectedFiles: Parameters[0]) => { + const r = rejectedFiles[0]; + + // `file-too-large` backend error message is not friendly. + // It'll display the max size in bytes which the average user may not understand. + const errors = r.errors.map((error) => { + return error.code === 'file-too-large' + ? { + ...error, + message: `File is larger than ${bytesToMegabytes(UPLOADER_MAX_SIZE)} MB`, + } + : error; + }); + + addToast( + 'drop-error', + <> +

Error!

+
    + {errors.map((e) => ( +
  • {e.message}
  • + ))} +
+ , + { + level: 'error', + } + ); + }; + + const onUploadSubmit = useCallback( + (values: FormValues) => { + setLoading(true); + const { file, name, tag } = values; + + const data = new FormData(); + + data.append('file', file); + data.append('name', name); + data.append('tagName', tag); + + const mutationResponse = { + onSuccess: () => { + setSuccessFile({ ...successFile }); + onClose(); + addToast( + 'success-upload-feature-file', + <> +

Success!

+

File uploaded

+ , + { + level: 'success', + } + ); + }, + onError: (error: AxiosError | Error) => { + let errors: { status: number; title: string }[] = []; + + if (isAxiosError(error)) { + errors = [...error.response.data.errors]; + } else { + // ? in case of unknown error (not request error), display generic error message + errors = [{ status: 500, title: 'Something went wrong' }]; + } + + setSuccessFile(null); + + addToast( + 'error-upload-feature-csv', + <> +

Error

+
    + {errors.map((e) => ( +
  • {e.title}
  • + ))} +
+ , + { + level: 'error', + } + ); + }, + onSettled: () => { + setLoading(false); + }, + }; + + if (uploadMode === 'shapefile') { + uploadFeaturesShapefileMutation.mutate({ data, id: `${pid}` }, mutationResponse); + } + + if (uploadMode === 'csv') { + uploadFeaturesCSVMutation.mutate({ data, id: `${pid}` }, mutationResponse); + } + }, + [ + pid, + addToast, + onClose, + uploadMode, + uploadFeaturesShapefileMutation, + uploadFeaturesCSVMutation, + successFile, + ] + ); + + const handleKeyPress = useCallback( + (event: Parameters['onKeyDown']>[0]) => { + if (event.key === 'Enter') { + setTagIsDone(true); + formRef.current.change('tag', event.currentTarget.value); + setTagsMenuOpen(false); + } + }, + [formRef] + ); + + const { getRootProps, getInputProps, isDragActive, isDragAccept, isDragReject } = useDropzone({ + multiple: false, + maxSize: UPLOADER_MAX_SIZE, + onDropAccepted, + onDropRejected, + }); + + const onDownloadTemplate = useCallback(() => { + downloadShapefileTemplateMutation.mutate( + { pid }, + { + onError: () => { + addToast( + 'download-error', + <> +

Error!

+
    Template not downloaded
+ , + { + level: 'error', + } + ); + }, + } + ); + }, [pid, downloadShapefileTemplateMutation, addToast]); + + return ( + + + initialValues={{ + tag: '', + }} + ref={formRef} + onSubmit={onUploadSubmit} + render={({ form, handleSubmit, values }) => { + formRef.current = form; + + return ( +
+
+
+

Upload feature

+ + + +
+ + saveUploadMode(mode)} /> + {uploadMode === 'csv' && ( +

+ Please download and fill in the{' '} + {' '} + before upload. +

+ )} + + {uploadMode === 'shapefile' && ( +
+ + {(fprops) => ( + + + + + )} + +
+ )} + + {uploadMode === 'shapefile' && ( +
+ + {(fprops) => ( + + + + {(!values.tag || !tagIsDone) && ( +
+ setTagsMenuOpen(true)} + onBlur={() => setTagIsDone(true)} + onKeyDown={handleKeyPress} + /> + + {tagsMenuOpen && ( +
+
Recent:
+
+ {tagsQuery.data?.map((tag) => ( + + ))} +
+
+ )} +
+ )} + + {values.tag && tagIsDone && ( +
+
+

{values.tag}

+
+ +
+ )} +
+ )} +
+
+ )} + + {!successFile && ( +
+ + + {(props) => ( +
+
+ + +

+ Drag and drop your{' '} + {uploadMode === 'shapefile' ? 'polygon data file' : 'feature file'} +
+ or click here to upload +

+ +

{`Recommended file size < ${bytesToMegabytes( + UPLOADER_MAX_SIZE + )} MB`}

+ + +
+ +
+
Supported formats
+ + + {' '} +

+ List of supported file formats: +

+
    + Zipped: .shp (zipped shapefiles must include +
    + .shp, .shx, .dbf, and .prj files) +
+
+
+
+
+ )} +
+
+ )} + + {successFile && ( + +
+
Uploaded file:
+
+ + +
+
+
+ )} + +
+ + + +
+
+
+ ); + }} + /> + +
+ ); +}; + +export default FeatureUploadModal;