diff --git a/client/modules/_hooks/src/datafiles/index.ts b/client/modules/_hooks/src/datafiles/index.ts index 8b8ed932e8..e97323bcc2 100644 --- a/client/modules/_hooks/src/datafiles/index.ts +++ b/client/modules/_hooks/src/datafiles/index.ts @@ -20,3 +20,4 @@ export { usePathDisplayName, getSystemRootDisplayName, } from './usePathDisplayName'; +export * from './projects'; diff --git a/client/modules/_hooks/src/datafiles/projects/index.ts b/client/modules/_hooks/src/datafiles/projects/index.ts new file mode 100644 index 0000000000..fcbb4f5691 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/index.ts @@ -0,0 +1,14 @@ +export * from './types'; +export { useProjectListing } from './useProjectListing'; +export { + useProjectDetail, + useFileAssociations, + useFileTags, +} from './useProjectDetail'; +export { useProjectPreview } from './useProjectPreview'; +export { useProjectEntityReorder } from './useProjectEntityReorder'; +export { useAddEntityToTree } from './useAddEntityToTree'; +export { useRemoveEntityFromTree } from './useRemoveEntityFromTree'; +export { useAddFileAssociation } from './useAddFileAssociation'; +export { useRemoveFileAssociation } from './useRemoveFileAssociation'; +export { useSetFileTags } from './useSetFileTags'; diff --git a/client/modules/_hooks/src/datafiles/projects/types.ts b/client/modules/_hooks/src/datafiles/projects/types.ts new file mode 100644 index 0000000000..0d1b65b6d8 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/types.ts @@ -0,0 +1,118 @@ +export type TProjectUser = { + fname: string; + lname: string; + email: string; + inst: string; + role: 'pi' | 'co_pi' | 'team_member' | 'guest'; + username?: string; +}; + +export type TProjectAward = { + name: string; + number: string; + fundingSource: string; +}; + +export type TReferencedWork = { + title: string; + doi: string; + hrefType: 'doi' | 'url'; +}; + +export type TAssociatedProject = { + type: 'Context' | 'Linked Dataset' | 'Cited By'; + title: string; + href: string; + hrefType: 'doi' | 'url'; +}; + +export type TFileTag = { + tagName: string; + path: string; +}; + +export type TFileObj = { + system: string; + name: string; + path: string; + type: 'file' | 'dir'; + length?: number; + lastModified?: string; +}; + +export type THazmapperMap = { + name: string; + uuid: string; + path: string; + deployment: string; + href?: string; +}; + +export type TDropdownValue = { + id: string; + name: string; +}; + +export type TNHEvent = { + eventName: string; + eventStart: string; + eventEnd: string; + location: string; + latitude: string; + longitude: string; +}; + +export type TBaseProjectValue = { + projectId: string; + projectType: + | 'other' + | 'experimental' + | 'simulation' + | 'hybrid_simulation' + | 'field_recon' + | 'field_reconnaissance' + | 'None'; + + title: string; + description: string; + users: TProjectUser[]; + dataTypes?: TDropdownValue[]; + authors: TProjectUser[]; + + awardNumbers: TProjectAward[]; + associatedProjects: TAssociatedProject[]; + referencedData: TReferencedWork[]; + keywords: string[]; + nhEvents: TNHEvent[]; + nhTypes: TDropdownValue[]; + frTypes?: TDropdownValue[]; + facilities: TDropdownValue[]; + + dois: string[]; + fileObjs: TFileObj[]; + fileTags: TFileTag[]; +}; + +type TEntityValue = { + title: string; + description?: string; + authors?: TProjectUser[]; + fileObjs?: TFileObj[]; + fileTags: TFileTag[]; +}; + +export type TProjectMeta = { + uuid: string; + name: string; + created: string; + lastUpdated: string; +}; + +export type TBaseProject = TProjectMeta & { + name: 'designsafe.project'; + value: TBaseProjectValue; +}; + +export type TEntityMeta = TProjectMeta & { + value: TEntityValue; +}; diff --git a/client/modules/_hooks/src/datafiles/projects/useAddEntityToTree.ts b/client/modules/_hooks/src/datafiles/projects/useAddEntityToTree.ts new file mode 100644 index 0000000000..b572266539 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useAddEntityToTree.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function addEntity(projectId: string, nodeId: string, uuid: string) { + const res = await apiClient.post( + `/api/projects/v2/${projectId}/entities/associations/${nodeId}/`, + { uuid } + ); + return res.data; +} + +export function useAddEntityToTree(projectId: string, nodeId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ uuid }: { uuid: string }) => + addEntity(projectId, nodeId, uuid), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useAddFileAssociation.ts b/client/modules/_hooks/src/datafiles/projects/useAddFileAssociation.ts new file mode 100644 index 0000000000..1cbe66322c --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useAddFileAssociation.ts @@ -0,0 +1,32 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; +import { TFileObj } from './types'; + +async function addFileAssociation( + projectId: string, + entityUuid: string, + fileObjs: TFileObj[] +) { + const res = await apiClient.post( + `/api/projects/v2/${projectId}/entities/${entityUuid}/files/`, + { fileObjs } + ); + return res.data; +} + +export function useAddFileAssociation(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + fileObjs, + entityUuid, + }: { + fileObjs: TFileObj[]; + entityUuid: string; + }) => addFileAssociation(projectId, entityUuid, fileObjs), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts b/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts new file mode 100644 index 0000000000..3035c52762 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts @@ -0,0 +1,73 @@ +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; +import { TBaseProject, TEntityMeta, TFileTag } from './types'; +import { useMemo } from 'react'; + +type TProjectDetailResponse = { + baseProject: TBaseProject; + entities: TEntityMeta[]; + tree: unknown; +}; + +async function getProjectDetail({ + projectId, + signal, +}: { + projectId: string; + signal: AbortSignal; +}) { + const resp = await apiClient.get( + `/api/projects/v2/${projectId}/`, + { + signal, + } + ); + return resp.data; +} + +export function useProjectDetail(projectId: string) { + return useQuery({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + queryFn: ({ signal }) => getProjectDetail({ projectId, signal }), + enabled: !!projectId, + }); +} + +export function useFileAssociations(projectId: string) { + /*Return a record mapping file paths to an array of entities containing those paths.*/ + const { data } = useProjectDetail(projectId); + + const memoizedFileMapping = useMemo(() => { + const entities = data?.entities ?? []; + const fileMapping: Record = {}; + entities.forEach((entity) => { + const fileObjs = entity.value.fileObjs ?? []; + fileObjs.forEach((fileObj) => { + const entityList = fileMapping[fileObj.path] ?? []; + entityList.push(entity); + fileMapping[fileObj.path] = entityList; + }); + }); + return fileMapping; + }, [data]); + + return memoizedFileMapping; +} + +export function useFileTags(projectId: string) { + /*Return a record mapping file paths to an array of entities containing those paths.*/ + const { data } = useProjectDetail(projectId); + + const memoizedFileMapping = useMemo(() => { + const entities = data?.entities ?? []; + const tagMapping: Record = {}; + entities.forEach((entity) => { + const fileTags = entity.value.fileTags ?? []; + tagMapping[entity.uuid] = fileTags; + }); + + return tagMapping; + }, [data]); + + return memoizedFileMapping; +} diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectEntityReorder.ts b/client/modules/_hooks/src/datafiles/projects/useProjectEntityReorder.ts new file mode 100644 index 0000000000..7d70271eba --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useProjectEntityReorder.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function reorderEntitity( + projectId: string, + nodeId: string, + order: number +) { + const res = await apiClient.put( + `/api/projects/v2/${projectId}/entities/ordering/`, + { nodeId, order } + ); + return res.data; +} + +export function useProjectEntityReorder(projectId: string, nodeId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ order }: { order: number }) => + reorderEntitity(projectId, nodeId, order), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts b/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts new file mode 100644 index 0000000000..5d7aabe895 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; +import { TBaseProject } from './types'; + +export type TProjectListingResponse = { + total: number; + result: TBaseProject[]; +}; + +async function getProjectListing({ + page = 1, + limit = 100, + signal, +}: { + page: number; + limit: number; + signal: AbortSignal; +}) { + const resp = await apiClient.get( + '/api/projects/v2', + { + signal, + params: { offset: (page - 1) * limit, limit }, + } + ); + return resp.data; +} + +export function useProjectListing(page: number, limit: number) { + return useQuery({ + queryKey: ['datafiles', 'projects', 'listing', page, limit], + queryFn: ({ signal }) => getProjectListing({ page, limit, signal }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx b/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx new file mode 100644 index 0000000000..e6da9b8255 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; +import { TBaseProject, TEntityMeta } from './types'; + +type TProjectPreviewResponse = { + baseProject: TBaseProject; + entities: TEntityMeta[]; + tree: unknown; +}; + +async function getProjectPreview({ + projectId, + signal, +}: { + projectId: string; + signal: AbortSignal; +}) { + const resp = await apiClient.get( + `/api/projects/v2/${projectId}/preview/`, + { + signal, + } + ); + return resp.data; +} + +export function useProjectPreview(projectId: string) { + return useQuery({ + queryKey: ['datafiles', 'projects', 'detail', projectId, 'preview'], + queryFn: ({ signal }) => getProjectPreview({ projectId, signal }), + enabled: !!projectId, + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useRemoveEntityFromTree.ts b/client/modules/_hooks/src/datafiles/projects/useRemoveEntityFromTree.ts new file mode 100644 index 0000000000..6f3c3a93c2 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useRemoveEntityFromTree.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function removeEntity(projectId: string, nodeId: string) { + const res = await apiClient.delete( + `/api/projects/v2/${projectId}/entities/associations/${nodeId}/` + ); + return res.data; +} + +export function useRemoveEntityFromTree(projectId: string, nodeId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: () => removeEntity(projectId, nodeId), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useRemoveFileAssociation.ts b/client/modules/_hooks/src/datafiles/projects/useRemoveFileAssociation.ts new file mode 100644 index 0000000000..6039cd2709 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useRemoveFileAssociation.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function removeFileAssociation( + projectId: string, + entityUuid: string, + filePath: string +) { + const res = await apiClient.delete( + `/api/projects/v2/${projectId}/entities/${entityUuid}/files/${filePath}/` + ); + return res.data; +} + +export function useRemoveFileAssociation(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + filePath, + entityUuid, + }: { + filePath: string; + entityUuid: string; + }) => removeFileAssociation(projectId, entityUuid, filePath), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/useSetFileTags.ts b/client/modules/_hooks/src/datafiles/projects/useSetFileTags.ts new file mode 100644 index 0000000000..26eb603247 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/useSetFileTags.ts @@ -0,0 +1,31 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function setFileTags( + projectId: string, + entityUuid: string, + filePath: string, + tagNames: string[] +) { + const res = await apiClient.put( + `/api/projects/v2/${projectId}/entities/${entityUuid}/file-tags/${filePath}/`, + { tagNames } + ); + return res.data; +} + +export function useSetFileTags( + projectId: string, + entityUuid: string, + filePath: string +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ tagNames }: { tagNames: string[] }) => + setFileTags(projectId, entityUuid, filePath, tagNames), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail', projectId], + }), + }); +} diff --git a/client/modules/_hooks/src/index.ts b/client/modules/_hooks/src/index.ts index b5c26de8b1..3e5fdb8b68 100644 --- a/client/modules/_hooks/src/index.ts +++ b/client/modules/_hooks/src/index.ts @@ -1,3 +1,5 @@ export { useAuthenticatedUser } from './useAuthenticatedUser'; +export { useDebounceValue } from './useDebounceValue'; +export { default as apiClient } from './apiClient'; export * from './workspace'; export * from './datafiles'; diff --git a/client/modules/_hooks/src/useDebounceValue.ts b/client/modules/_hooks/src/useDebounceValue.ts new file mode 100644 index 0000000000..5e82b10028 --- /dev/null +++ b/client/modules/_hooks/src/useDebounceValue.ts @@ -0,0 +1,17 @@ +import { useState, useEffect } from 'react'; + +export function useDebounceValue(value: T, delay: number) { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/client/modules/datafiles/src/DatafilesModal/PreviewModal/PreviewModal.tsx b/client/modules/datafiles/src/DatafilesModal/PreviewModal/PreviewModal.tsx index c3dd3bd6a8..90349ddc82 100644 --- a/client/modules/datafiles/src/DatafilesModal/PreviewModal/PreviewModal.tsx +++ b/client/modules/datafiles/src/DatafilesModal/PreviewModal/PreviewModal.tsx @@ -34,6 +34,8 @@ export const PreviewModalBody: React.FC<{ handleCancel(); }, [handleCancel, queryClient]); + if (!isOpen) return null; + return ( File Preview: {path}} @@ -86,14 +88,16 @@ export const PreviewModal: TPreviewModal = ({ return ( <> {React.createElement(children, { onClick: showModal })} - + {isModalOpen && ( + + )} ); }; diff --git a/client/modules/datafiles/src/FileListing/FileListing.tsx b/client/modules/datafiles/src/FileListing/FileListing.tsx index 45a9a406ac..252286e60d 100644 --- a/client/modules/datafiles/src/FileListing/FileListing.tsx +++ b/client/modules/datafiles/src/FileListing/FileListing.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; //import styles from './FileListing.module.css'; -import { Button } from 'antd'; +import { Button, TableProps } from 'antd'; import { FileListingTable, TFileListingColumns, @@ -18,12 +18,14 @@ export function toBytes(bytes?: number) { return `${bytesInUnits.toFixed(precision)} ${units[orderOfMagnitude]}`; } -export const FileListing: React.FC<{ - api: string; - system: string; - path?: string; - scheme?: string; -}> = ({ api, system, path = '', scheme = 'private' }) => { +export const FileListing: React.FC< + { + api: string; + system: string; + path?: string; + scheme?: string; + } & Omit +> = ({ api, system, path = '', scheme = 'private', ...tableProps }) => { // Base file listing for use with My Data/Community Data const [previewModalState, setPreviewModalState] = useState<{ isOpen: boolean; @@ -94,6 +96,7 @@ export const FileListing: React.FC<{ scheme={scheme} path={path} columns={columns} + {...tableProps} /> {previewModalState.path && (   +
 
) : (
Placeholder for empty data.
), diff --git a/client/modules/datafiles/src/index.ts b/client/modules/datafiles/src/index.ts index d3397d03df..236fbbc70f 100644 --- a/client/modules/datafiles/src/index.ts +++ b/client/modules/datafiles/src/index.ts @@ -5,3 +5,4 @@ export * from './FileListing/FileListing'; export { default as DatafilesModal } from './DatafilesModal/DatafilesModal'; export * from './DatafilesToolbar/DatafilesToolbar'; export * from './DatafilesBreadcrumb/DatafilesBreadcrumb'; +export * from './projects'; diff --git a/client/modules/datafiles/src/projects/BaseProjectDetails.module.css b/client/modules/datafiles/src/projects/BaseProjectDetails.module.css new file mode 100644 index 0000000000..10737750ae --- /dev/null +++ b/client/modules/datafiles/src/projects/BaseProjectDetails.module.css @@ -0,0 +1,12 @@ +.line-clamped { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; +} + +.line-unclamped { + display: -webkit-box; + -webkit-box-orient: vertical; + overflow: hidden; +} diff --git a/client/modules/datafiles/src/projects/BaseProjectDetails.tsx b/client/modules/datafiles/src/projects/BaseProjectDetails.tsx new file mode 100644 index 0000000000..58ad589c85 --- /dev/null +++ b/client/modules/datafiles/src/projects/BaseProjectDetails.tsx @@ -0,0 +1,139 @@ +import React, { useEffect, useState, useCallback } from 'react'; +import { TBaseProjectValue } from '@client/hooks'; + +import styles from './BaseProjectDetails.module.css'; +import { Button } from 'antd'; + +export const DescriptionExpander: React.FC = ({ + children, +}) => { + const [expanderRef, setExpanderRef] = useState(null); + const [expanded, setExpanded] = useState(false); + const [expandable, setExpandable] = useState(false); + + const expanderRefCallback = useCallback( + (node: HTMLElement) => { + if (node !== null) setExpanderRef(node); + }, + [setExpanderRef] + ); + + useEffect(() => { + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + setExpandable(entry.target.scrollHeight > entry.target.clientHeight); + } + }); + expanderRef && ro.observe(expanderRef); + return () => { + ro.disconnect(); + }; + }, [setExpandable, expanderRef]); + + return ( +
+ + {children} + + {(expandable || expanded) && ( + + )} +
+ ); +}; + +export const BaseProjectDetails: React.FC<{ + projectValue: TBaseProjectValue; +}> = ({ projectValue }) => { + const pi = projectValue.users.find((u) => u.role === 'pi'); + const coPis = projectValue.users.filter((u) => u.role === 'co_pi'); + + return ( + <> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PI{`${pi?.lname}, ${pi?.fname}`}
Co-PIs + {coPis.map((u) => `${u.lname}, ${u.fname}`).join(', ')} +
Project Type{projectValue.projectType}
Data Types + {projectValue.dataTypes?.map((d) => d.name).join(', ')} +
Natural Hazard Type{`${projectValue.nhTypes + .map((t) => t.name) + .join(', ')}`}
Events + {projectValue.nhEvents.map((evt) => ( +
+ {evt.eventName} | {evt.location} {evt.eventStart}- + {evt.eventEnd} | Lat {evt.latitude} long {evt.longitude} +
+ ))} +
Awards + {projectValue.awardNumbers.map((t) => ( +
+ {[t.name, t.number, t.fundingSource] + .filter((v) => !!v) + .join(' | ')}{' '} +
+ ))} +
Keywords + {projectValue.keywords.join(', ')} +
+ + Description: + {projectValue.description} + + + ); +}; diff --git a/client/modules/datafiles/src/projects/ProjectCurationFileListing/ProjectCurationFileListing.tsx b/client/modules/datafiles/src/projects/ProjectCurationFileListing/ProjectCurationFileListing.tsx new file mode 100644 index 0000000000..cd416db0b4 --- /dev/null +++ b/client/modules/datafiles/src/projects/ProjectCurationFileListing/ProjectCurationFileListing.tsx @@ -0,0 +1,339 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { + FileListingTable, + TFileListingColumns, +} from '../../FileListing/FileListingTable/FileListingTable'; +import { toBytes } from '../../FileListing/FileListing'; +import { PreviewModalBody } from '../../DatafilesModal/PreviewModal'; +import { NavLink } from 'react-router-dom'; +import { + TEntityMeta, + TFileListing, + TFileTag, + useAddFileAssociation, + useFileAssociations, + useFileTags, + useProjectDetail, + useRemoveFileAssociation, + useSetFileTags, +} from '@client/hooks'; +import { Button, Select } from 'antd'; +import { + DISPLAY_NAMES, + ENTITIES_WITH_FILES, + PROJECT_COLORS, +} from '../constants'; +import { DefaultOptionType } from 'antd/es/select'; +import { FILE_TAG_OPTIONS } from './ProjectFileTagOptions'; + +const FileTagInput: React.FC<{ + projectId: string; + filePath: string; + entityUuid: string; + entityName: string; + initialTags: string[]; +}> = ({ projectId, filePath, entityUuid, entityName, initialTags }) => { + const [tagValue, setTagValue] = useState(initialTags); + const [showSave, setShowSave] = useState(false); + useEffect(() => setTagValue(initialTags), [setTagValue, initialTags]); + + const { mutate: setFileTags } = useSetFileTags( + projectId, + entityUuid, + encodeURIComponent(filePath) + ); + + const onTagChange = useCallback( + (newVal: string[]) => { + setTagValue(newVal); + setShowSave(true); + }, + [setTagValue, setShowSave] + ); + + const onSaveSelection = useCallback(() => { + setFileTags( + { tagNames: tagValue }, + { onSuccess: () => setShowSave(false) } + ); + }, [tagValue, setFileTags]); + + return ( +
+ setSelectedEntity(newVal)} + options={options} + >{' '} +   + {selectedEntity && ( + + )} + + ); +}; + +const ProjectTreeDisplay: React.FC<{ + projectId: string; + uuid: string; + nodeId: string; + order: number; + name: string; + isLast: boolean; +}> = ({ projectId, uuid, nodeId, order, name, isLast }) => { + const { data } = useProjectDetail(projectId); + const { mutate } = useProjectEntityReorder(projectId, nodeId); + const { mutate: removeEntity } = useRemoveEntityFromTree(projectId, nodeId); + if (!data) return null; + const { entities } = data; + const entity = entities.find((e) => e.uuid === uuid); + if (!entity) return null; + return ( + <> +  {entity.value.title}  + + + {!PUBLISHABLE_NAMES.includes(name) && ( + + )} + + ); +}; + +type TTreeData = { + name: string; + id: string; + uuid: string; + order: number; + children: TTreeData[]; +}; + +function RecursiveTree({ + treeData, + projectId, + isLast = false, +}: { + treeData: TTreeData; + projectId: string; + isLast?: boolean; +}) { + const sortedChildren = useMemo( + () => [...(treeData.children ?? [])].sort((a, b) => a.order - b.order), + [treeData] + ); + + const showDropdown = + ALLOWED_RELATIONS[treeData.name] && treeData.name !== 'designsafe.project'; + + return ( +
  • + + {DISPLAY_NAMES[treeData.name]} + + +
      + {sortedChildren.map((child, idx) => ( + + ))} + {showDropdown && ( +
    • + + + +
    • + )} +
    +
  • + ); +} + +export const ProjectTree: React.FC<{ projectId: string }> = ({ projectId }) => { + const { data } = useProjectDetail(projectId); + const treeJSON = data?.tree as TTreeData; + + if (!treeJSON) return
    project tree
    ; + return ( +
      + +
    + ); +}; diff --git a/client/modules/datafiles/src/projects/ProjectTree/projectColors.ts b/client/modules/datafiles/src/projects/ProjectTree/projectColors.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/modules/datafiles/src/projects/constants.ts b/client/modules/datafiles/src/projects/constants.ts new file mode 100644 index 0000000000..b92f75abdc --- /dev/null +++ b/client/modules/datafiles/src/projects/constants.ts @@ -0,0 +1,208 @@ +import { TBaseProjectValue } from '@client/hooks'; + +export const PROJECT = 'designsafe.project'; +//const PROJECT_GRAPH = 'designsafe.project.graph'; +// Experimental +export const EXPERIMENT = 'designsafe.project.experiment'; +export const EXPERIMENT_REPORT = 'designsafe.project.report'; +export const EXPERIMENT_ANALYSIS = 'designsafe.project.analysis'; +export const EXPERIMENT_MODEL_CONFIG = 'designsafe.project.model_config'; +export const EXPERIMENT_SENSOR = 'designsafe.project.sensor_list'; +export const EXPERIMENT_EVENT = 'designsafe.project.event'; +// Simulation +export const SIMULATION = 'designsafe.project.simulation'; +export const SIMULATION_REPORT = 'designsafe.project.simulation.report'; +export const SIMULATION_ANALYSIS = 'designsafe.project.simulation.analysis'; +export const SIMULATION_MODEL = 'designsafe.project.simulation.model'; +export const SIMULATION_INPUT = 'designsafe.project.simulation.input'; +export const SIMULATION_OUTPUT = 'designsafe.project.simulation.output'; +// Field Research +export const FIELD_RECON_MISSION = 'designsafe.project.field_recon.mission'; +export const FIELD_RECON_REPORT = 'designsafe.project.field_recon.report'; +export const FIELD_RECON_SOCIAL_SCIENCE = + 'designsafe.project.field_recon.social_science'; +export const FIELD_RECON_PLANNING = 'designsafe.project.field_recon.planning'; +export const FIELD_RECON_GEOSCIENCE = + 'designsafe.project.field_recon.geoscience'; +// Hybrid Sim +export const HYBRID_SIM = 'designsafe.project.hybrid_simulation'; +export const HYBRID_SIM_GLOBAL_MODEL = + 'designsafe.project.hybrid_simulation.global_model'; +export const HYBRID_SIM_COORDINATOR = + 'designsafe.project.hybrid_simulation.coordinator'; +export const HYBRID_SIM_SIM_SUBSTRUCTURE = + 'designsafe.project.hybrid_simulation.sim_substructure'; +export const HYBRID_SIM_EXP_SUBSTRUCTURE = + 'designsafe.project.hybrid_simulation.exp_substructure'; +export const HYBRID_SIM_COORDINATOR_OUTPUT = + 'designsafe.project.hybrid_simulation.coordinator_output'; +export const HYBRID_SIM_SIM_OUTPUT = + 'designsafe.project.hybrid_simulation.sim_output'; +export const HYBRID_SIM_EXP_OUTPUT = + 'designsafe.project.hybrid_simulation.exp_output'; +export const HYBRID_SIM_ANALYSIS = + 'designsafe.project.hybrid_simulation.analysis'; +export const HYBRID_SIM_REPORT = 'designsafe.project.hybrid_simulation.report'; + +export const PROJECT_COLORS: Record = + { + [PROJECT]: { outline: 'black', fill: 'white' }, + [EXPERIMENT]: { outline: 'black', fill: 'white' }, + [EXPERIMENT_REPORT]: { outline: '#cccccc', fill: '#f5f5f5' }, + [EXPERIMENT_ANALYSIS]: { outline: '#56C0E0', fill: '#CCECF6' }, + [EXPERIMENT_MODEL_CONFIG]: { outline: '#1568C9', fill: '#C4D9F2' }, + [EXPERIMENT_SENSOR]: { outline: '#43A59D', fill: '#CAE9E6' }, + [EXPERIMENT_EVENT]: { outline: '#B59300', fill: '#ECE4BF' }, + + [SIMULATION]: { outline: '#cccccc', fill: '#f5f5f5' }, + [SIMULATION_REPORT]: { outline: '#cccccc', fill: '#f5f5f5' }, + [SIMULATION_ANALYSIS]: { outline: '##56C0E0', fill: '#CCECF6' }, + [SIMULATION_MODEL]: { outline: '#1568C9', fill: '#C4D9F2' }, + [SIMULATION_INPUT]: { outline: '#43A59D', fill: '#CAE9E6' }, + [SIMULATION_OUTPUT]: { outline: '#B59300', fill: '#B59300' }, + + [HYBRID_SIM]: { outline: '#cccccc', fill: '#f5f5f5' }, + [HYBRID_SIM_ANALYSIS]: { outline: '##56C0E0', fill: '#CCECF6' }, + [HYBRID_SIM_REPORT]: { outline: '#cccccc', fill: '#f5f5f5' }, + [HYBRID_SIM_GLOBAL_MODEL]: { outline: '#1568C9', fill: '#C4D9F2' }, + [HYBRID_SIM_COORDINATOR]: { outline: '#43A59D', fill: '#CAE9E6' }, + [HYBRID_SIM_COORDINATOR_OUTPUT]: { outline: '#B59300', fill: '#ECE4BF' }, + [HYBRID_SIM_EXP_SUBSTRUCTURE]: { outline: '#4B3181', fill: '#C8C0D9' }, + [HYBRID_SIM_EXP_OUTPUT]: { outline: '#B59300', fill: '#ECE4BF' }, + [HYBRID_SIM_SIM_SUBSTRUCTURE]: { outline: '#BD5717', fill: '#EBCCB9' }, + [HYBRID_SIM_SIM_OUTPUT]: { outline: '#B59300', fill: '#ECE4BF' }, + + [FIELD_RECON_REPORT]: { outline: '#cccccc', fill: '#f5f5f5' }, + [FIELD_RECON_MISSION]: { outline: '#000000', fill: '#ffffff' }, + [FIELD_RECON_GEOSCIENCE]: { outline: '#43A59D', fill: '#CAE9E6' }, + [FIELD_RECON_PLANNING]: { outline: '#B59300', fill: '#ECE4BF' }, + [FIELD_RECON_SOCIAL_SCIENCE]: { outline: '#43A59D', fill: '#CAE9E6' }, + }; + +export const ALLOWED_RELATIONS: Record = { + [PROJECT]: [ + EXPERIMENT, + SIMULATION, + HYBRID_SIM, + FIELD_RECON_MISSION, + FIELD_RECON_REPORT, + ], + // Experimental + [EXPERIMENT]: [ + EXPERIMENT_ANALYSIS, + EXPERIMENT_REPORT, + EXPERIMENT_MODEL_CONFIG, + ], + [EXPERIMENT_MODEL_CONFIG]: [EXPERIMENT_SENSOR], + [EXPERIMENT_SENSOR]: [EXPERIMENT_EVENT], + // Simulation + [SIMULATION]: [SIMULATION_ANALYSIS, SIMULATION_REPORT, SIMULATION_MODEL], + [SIMULATION_MODEL]: [SIMULATION_INPUT], + [SIMULATION_INPUT]: [SIMULATION_OUTPUT], + // Hybrid sim + [HYBRID_SIM]: [ + HYBRID_SIM_REPORT, + HYBRID_SIM_GLOBAL_MODEL, + HYBRID_SIM_ANALYSIS, + ], + [HYBRID_SIM_GLOBAL_MODEL]: [HYBRID_SIM_COORDINATOR], + [HYBRID_SIM_COORDINATOR]: [ + HYBRID_SIM_COORDINATOR_OUTPUT, + HYBRID_SIM_SIM_SUBSTRUCTURE, + HYBRID_SIM_EXP_SUBSTRUCTURE, + ], + [HYBRID_SIM_SIM_SUBSTRUCTURE]: [HYBRID_SIM_SIM_OUTPUT], + [HYBRID_SIM_EXP_SUBSTRUCTURE]: [HYBRID_SIM_EXP_OUTPUT], + // Field Recon + [FIELD_RECON_MISSION]: [ + FIELD_RECON_PLANNING, + FIELD_RECON_SOCIAL_SCIENCE, + FIELD_RECON_GEOSCIENCE, + ], +}; + +// Configures which entity types can have files associated to them. +export const ENTITIES_WITH_FILES: Record< + TBaseProjectValue['projectType'], + string[] +> = { + experimental: [ + EXPERIMENT_ANALYSIS, + EXPERIMENT_REPORT, + EXPERIMENT_MODEL_CONFIG, + EXPERIMENT_SENSOR, + EXPERIMENT_EVENT, + ], + simulation: [ + SIMULATION_ANALYSIS, + SIMULATION_REPORT, + SIMULATION_MODEL, + SIMULATION_INPUT, + SIMULATION_OUTPUT, + ], + hybrid_simulation: [ + HYBRID_SIM_ANALYSIS, + HYBRID_SIM_REPORT, + HYBRID_SIM_COORDINATOR, + HYBRID_SIM_COORDINATOR_OUTPUT, + HYBRID_SIM_GLOBAL_MODEL, + HYBRID_SIM_EXP_OUTPUT, + HYBRID_SIM_EXP_SUBSTRUCTURE, + HYBRID_SIM_SIM_OUTPUT, + HYBRID_SIM_SIM_SUBSTRUCTURE, + ], + field_recon: [ + FIELD_RECON_GEOSCIENCE, + FIELD_RECON_MISSION, + FIELD_RECON_PLANNING, + FIELD_RECON_REPORT, + FIELD_RECON_SOCIAL_SCIENCE, + ], + field_reconnaissance: [], + other: [PROJECT], + None: [], +}; + +export const DISPLAY_NAMES: Record = { + [PROJECT]: 'Project', + // Experimental + [EXPERIMENT]: 'Experiment', + [EXPERIMENT_MODEL_CONFIG]: 'Model Configuration', + [EXPERIMENT_SENSOR]: 'Sensor', + [EXPERIMENT_ANALYSIS]: 'Analysis', + [EXPERIMENT_EVENT]: 'Event', + [EXPERIMENT_REPORT]: 'Report', + // Simulation + [SIMULATION]: 'Simulation', + [SIMULATION_MODEL]: 'Simulation Model', + [SIMULATION_INPUT]: 'Simulation Input', + [SIMULATION_OUTPUT]: 'Simulation Output', + [SIMULATION_ANALYSIS]: 'Analysis', + [SIMULATION_REPORT]: 'Report', + // Hybrid sim + [HYBRID_SIM]: 'Hybrid Simulation', + [HYBRID_SIM_REPORT]: 'Report', + [HYBRID_SIM_ANALYSIS]: 'Analysis', + [HYBRID_SIM_GLOBAL_MODEL]: 'Global Model', + [HYBRID_SIM_COORDINATOR]: 'Simulation Coordinator', + [HYBRID_SIM_SIM_SUBSTRUCTURE]: 'Simulation Substructure', + [HYBRID_SIM_EXP_SUBSTRUCTURE]: 'Experimental Substructure', + [HYBRID_SIM_EXP_OUTPUT]: 'Experimental Output', + [HYBRID_SIM_COORDINATOR_OUTPUT]: 'Coordinator Output', + [HYBRID_SIM_SIM_OUTPUT]: 'Simulation Output', + // Field Recon + [FIELD_RECON_MISSION]: 'Mission', + [FIELD_RECON_GEOSCIENCE]: 'Geoscience Collection', + [FIELD_RECON_SOCIAL_SCIENCE]: 'Social Science Collection', + [FIELD_RECON_REPORT]: 'Document Collection', + [FIELD_RECON_PLANNING]: 'Planning Collection', +}; + +export const PUBLISHABLE_NAMES = [ + PROJECT, + EXPERIMENT, + SIMULATION, + HYBRID_SIM, + FIELD_RECON_MISSION, + FIELD_RECON_REPORT, +]; diff --git a/client/modules/datafiles/src/projects/forms/BaseProjectForm.tsx b/client/modules/datafiles/src/projects/forms/BaseProjectForm.tsx new file mode 100644 index 0000000000..809a12c7e2 --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/BaseProjectForm.tsx @@ -0,0 +1,214 @@ +import { Button, Form, Input, Select, Tag } from 'antd'; +import React, { useCallback, useEffect } from 'react'; +import { + nhTypeOptions, + facilityOptions, + dataTypeOptions, +} from './ProjectFormDropdowns'; +import { + UserSelect, + DropdownSelect, + GuestMembersInput, + HazardEventsInput, + AwardsInput, + RelatedWorkInput, + ReferencedDataInput, +} from './_fields'; +import { TProjectUser } from './_fields/UserSelect'; +import { TBaseProjectValue, useProjectDetail } from '@client/hooks'; + +const customizeRequiredMark = ( + label: React.ReactNode, + info: { required: boolean } +) => ( + <> + {label}  + {info.required && ( + + Required + + )} + +); + +export const BaseProjectForm: React.FC<{ projectId: string }> = ({ + projectId, +}) => { + const [form] = Form.useForm(); + const { data } = useProjectDetail(projectId ?? ''); + + function processFormData(formData: Record) { + const { pi, coPis, teamMembers, guestMembers, ...rest } = formData; + return { + ...rest, + users: [...pi, ...coPis, ...teamMembers, ...guestMembers], + }; + } + + const setValues = useCallback(() => { + if (data) form.setFieldsValue(cleanInitialvalues(data.baseProject.value)); + }, [data, form]); + + useEffect(() => setValues(), [setValues, projectId]); + + function cleanInitialvalues(projectData: TBaseProjectValue) { + const { users, ...rest } = projectData; + return { + ...rest, + pi: users.filter((u) => u.role === 'pi'), + coPis: users.filter((u) => u.role === 'co_pi'), + teamMembers: users.filter((u) => u.role === 'team_member'), + guestMembers: users.filter((u) => u.role === 'guest'), + }; + } + + //const watchedItem = Form.useWatch([], form); + if (!data) return
    Loading
    ; + return ( +
    console.log(processFormData(v))} + onFinishFailed={(v) => console.log(processFormData(v.values))} + requiredMark={customizeRequiredMark} + > + + Incorporate the project's focus with words indicating the hazard, model, + system, and research approach. Define all acronyms. + + + + + + + Specify the natural hazard being researched. + + + + + + + The nature or genre of the content. + + + + + + + Specify the facilities involved in this research. + + + + + +
    + + These users can view, edit, curate, and publish. Include Co-PI(s). + + + + + +   + + + + +
    + + + These users can view, edit, curate, and publish. + + + + + + + Add members without a DesignSafe account. These names can be selected as + authors during the publication process. + + + + + Recommended for funded projects. + + + + + Published data used in the creation of this dataset. + + + + + Information giving context, a linked dataset on DesignSafe, or works + citing the DOI for this dataset. + + + + + Details related to specific events such as natural hazards (ex. + Hurricane Katrina). + + + + + Choose informative words that indicate the content of the project. + + + + + + + What is this project about? How can data in this project be reused? How + is this project unique? Who is the audience? Description must be between + 50 and 5000 characters in length. + + + + + + + + +
    + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/ProjectFormDropdowns.ts b/client/modules/datafiles/src/projects/forms/ProjectFormDropdowns.ts new file mode 100644 index 0000000000..f67d94187e --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/ProjectFormDropdowns.ts @@ -0,0 +1,136 @@ +export const nhTypeOptions = [ + { value: 'drought', label: 'Drought' }, + { value: 'earthquake', label: 'Earthquake' }, + { value: 'extreme temperatures', label: 'Extreme Temperatures' }, + { value: 'fire', label: 'Wildfire' }, + { value: 'flood', label: 'Flood' }, + { value: 'hurricane/tropical storm', label: 'Hurricane/Tropical Storm' }, + { value: 'landslide', label: 'Landslide' }, + { value: 'tornado', label: 'Tornado' }, + { value: 'tsunami', label: 'Tsunami' }, + { value: 'thunderstorm', label: 'Thunderstorm' }, + { value: 'storm surge', label: 'Storm Surge' }, + { value: 'pandemic', label: 'Pandemic' }, + { value: 'wind', label: 'Wind' }, +]; + +export const facilityOptions = [ + { + value: 'rapid-uw', + label: + 'RAPID - Natural Hazard and Disasters Reconnaissance Facility - University of Washington', + }, + { + value: 'converge-boulder', + label: + 'CONVERGE - Social Science/Interdisciplinary Resources and Extreme Events Coordination - University of Colorado Boulder', + }, + { value: 'geer', label: 'GEER - Geotechnical Extreme Event Reconnaissance' }, + { + value: 'iseeer', + label: + 'ISEEER - Interdisciplinary Science and Engineering Extreme Events Research', + }, + { value: 'neer', label: 'NEER - Nearshore Extreme Event Reconnaissance' }, + { + value: 'oseer', + label: 'OSEER - Operations and Systems Engineering Extreme Events Research', + }, + { value: 'pheer', label: 'PHEER - Public Health Extreme Events Research' }, + { + value: 'summeer', + label: + 'SUMMEER - Sustainable Material Management Extreme Events Reconnaissance', + }, + { value: 'sseer', label: 'SSEER - Social Science Extreme Events Research' }, + { + value: 'steer', + label: 'StEER - Structural Engineering Extreme Event Reconnaissance', + }, + { + value: 'ohhwrl-oregon', + label: + 'Large Wave Flume and Directional Wave Basin - Oregon State University', + }, + { + value: 'eqss-utaustin', + label: 'Mobile Field Shakers - University of Texas at Austin', + }, + { + value: 'cgm-ucdavis', + label: 'Center for Geotechnical Modeling - University of California, Davis', + }, + { + value: 'lhpost-sandiego', + label: + 'Six Degree of Freedom Large High-Performance Outdoor Shake Table (LHPOST6) - University of California, San Diego', + }, + { + value: 'wwhr-florida', + label: 'Wall of Wind - Florida International University', + }, + { + value: 'niche', + label: + 'National Full-Scale Testing Infrastructure for Community Hardening in Extreme Wind, Surge, and Wave Events (NICHE)', + }, + { + value: 'pfsml-florida', + label: 'Boundary Layer Wind Tunnel - University of Florida', + }, + { + value: 'rtmd-lehigh', + label: + 'Real-Time Multi-Directional (RTMD) Experimental Facility with Large-Scale Hybrid Simulation Testing Capabilities - LeHigh University', + }, + { value: 'simcntr', label: 'SimCenter' }, + { + value: 'nco-purdue', + label: 'Network Coordination Office - Purdue University', + }, + { + value: 'crbcrp', + label: 'Center for Risk-Based Community Resilience Planning', + }, + { value: 'uc-berkeley', label: 'UC Berkeley' }, + { value: 'ut-austin', label: 'University of Texas at Austin' }, + { + value: 'oh-hinsdale-osu', + label: 'O.H. Hinsdale Wave Research Laboratory, Oregon State University', + }, + { + value: 'seel-ucla', + label: + 'University of California, Los Angeles, Structural/Earthquake Engineering Laboratory', + }, +]; + +export const dataTypeOptions = [ + { value: 'archival materials', label: 'Archival Materials' }, + { value: 'audio', label: 'Audio' }, + { value: 'benchmark dataset', label: 'Benchmark Dataset' }, + { value: 'check sheet', label: 'Check Sheet' }, + { value: 'code', label: 'Code' }, + { value: 'database', label: 'Database' }, + { value: 'dataset', label: 'Dataset' }, + { value: 'engineering', label: 'Engineering' }, + { value: 'image', label: 'Image' }, + { value: 'interdisciplinary', label: 'Interdisciplinary' }, + { value: 'jupyter notebook', label: 'Jupyter Notebook' }, + { value: 'learning object', label: 'Learning Object' }, + { value: 'model', label: 'Model' }, + { value: 'paper', label: 'Paper' }, + { value: 'proceeding', label: 'Proceeding' }, + { value: 'poster', label: 'Poster' }, + { value: 'presentation', label: 'Presentation' }, + { value: 'report', label: 'Report' }, + { + value: 'research experience for undergraduates', + label: 'Research Experience for Undergraduates', + }, + { value: 'simcenter testbed', label: 'SimCenter Testbed' }, + { value: 'social sciences', label: 'Social Sciences' }, + { value: 'survey instrument', label: 'Survey Instrument' }, + { value: 'testbed', label: 'Testbed' }, + { value: 'video', label: 'Video' }, +]; diff --git a/client/modules/datafiles/src/projects/forms/_fields/AwardsInput.tsx b/client/modules/datafiles/src/projects/forms/_fields/AwardsInput.tsx new file mode 100644 index 0000000000..cf0b42d22f --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/AwardsInput.tsx @@ -0,0 +1,60 @@ +import { Button, Form, Input } from 'antd'; +import React from 'react'; + +export const AwardsInput: React.FC<{ name: string }> = ({ name }) => { + return ( + + {(fields, { add, remove }) => ( + <> + {[ + ...(fields.length === 0 ? [{ key: -1, name: -1 }] : []), //Pad the fields when empty to display a placeholder. + ...fields, + ].map(({ key, name }) => { + const disabled = fields.length === 0; + return ( +
    + + + + + + + + + + +
    + ); + })} + + + )} +
    + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/_fields/DateInput.tsx b/client/modules/datafiles/src/projects/forms/_fields/DateInput.tsx new file mode 100644 index 0000000000..1573957beb --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/DateInput.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Button, DatePicker, DatePickerProps } from 'antd'; +import dayjs from 'dayjs'; + +/* Date input that exposes the value as ISO timestamp instead of dayJS object */ +export const DateInput: React.FC< + { + value?: string; + onChange?: (value?: string) => void; + } & DatePickerProps +> = ({ value, onChange, ...props }) => { + return ( + <> + onChange && onChange(v ? v.toISOString() : undefined)} + format="MM/DD/YYYY" + {...props} + style={{ width: '100%', ...props.style }} + /> + {/* Explicit clear button is needed for keyboard usability */} + + + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/_fields/DropdownSelect.tsx b/client/modules/datafiles/src/projects/forms/_fields/DropdownSelect.tsx new file mode 100644 index 0000000000..388597f91d --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/DropdownSelect.tsx @@ -0,0 +1,40 @@ +import { Select, SelectProps } from 'antd'; +import React from 'react'; + +type DropdownValue = { id: string; name: string }; +export const DropdownSelect: React.FC<{ + maxCount?: number; + options: SelectProps['options']; + value?: DropdownValue[]; + onChange?: (value: DropdownValue[]) => void; +}> = ({ value, onChange, options, maxCount }) => { + const handleChange = (newVal: { label?: string; value: string }[]) => { + const formValue = newVal.map((v) => + v.label ? { id: v.value, name: v.label } : { id: 'other', name: v.value } + ); + + onChange && onChange(formValue); + }; + + const getValue = ( + formVal?: DropdownValue[] + ): { label?: string; value: string }[] | undefined => { + return formVal?.map((v) => + v.id === 'other' + ? { label: undefined, value: v.name } + : { label: v.name, value: v.id } + ); + }; + + return ( + + + + + + + + + + + + + +
    + ); + })} + + + )} + + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/_fields/HazardEventsInput.tsx b/client/modules/datafiles/src/projects/forms/_fields/HazardEventsInput.tsx new file mode 100644 index 0000000000..dfb1bfaade --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/HazardEventsInput.tsx @@ -0,0 +1,121 @@ +import { Button, Form, Input } from 'antd'; +import React from 'react'; +import { DateInput } from './DateInput'; + +export const HazardEventsInput: React.FC<{ name: string }> = ({ name }) => { + return ( + + {(fields, { add, remove }) => ( + <> + {[ + ...(fields.length === 0 ? [{ key: -1, name: -1 }] : []), //Pad the fields when empty to display a placeholder. + ...fields, + ].map(({ key, name }, i) => { + const showPlaceholder = fields.length === 0; + return ( + + {i !== 0 && ( +
    + )} +
    +
    +
    + + + + Start Date} + name={ + !showPlaceholder ? [name, 'eventStart'] : undefined + } + className="flex-1" + > + + + + + +
    +
    + + + + 0 ? [name, 'latitude'] : undefined + } + className="flex-1" + > + + + 0 }]} + label="Location" + name={ + !showPlaceholder ? [name, 'longitude'] : undefined + } + className="flex-1" + > + + +
    +
    + +
    +
    + ); + })} + + + )} +
    + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/_fields/ReferencedDataInput.tsx b/client/modules/datafiles/src/projects/forms/_fields/ReferencedDataInput.tsx new file mode 100644 index 0000000000..92be2c249d --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/ReferencedDataInput.tsx @@ -0,0 +1,73 @@ +import { Button, Form, Input, Radio } from 'antd'; +import React from 'react'; + +export const ReferencedDataInput: React.FC<{ name: string }> = ({ name }) => { + return ( + + {(fields, { add, remove }) => ( + <> + {[ + ...(fields.length === 0 ? [{ key: -1, name: -1 }] : []), //Pad the fields when empty to display a placeholder. + ...fields, + ].map(({ key, name }) => { + const disabled = fields.length === 0; + + return ( +
    + + + + +
    + + + + + + + DOI + URL + + +
    + + +
    + ); + })} + + + )} +
    + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/_fields/RelatedWorkInput.tsx b/client/modules/datafiles/src/projects/forms/_fields/RelatedWorkInput.tsx new file mode 100644 index 0000000000..1ed74a7015 --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/RelatedWorkInput.tsx @@ -0,0 +1,88 @@ +import { Button, Form, Input, Radio, Select } from 'antd'; +import React from 'react'; + +export const RelatedWorkInput: React.FC<{ name: string }> = ({ name }) => { + return ( + + {(fields, { add, remove }) => ( + <> + {[ + ...(fields.length === 0 ? [{ key: -1, name: -1 }] : []), //Pad the fields when empty to display a placeholder. + ...fields, + ].map(({ key, name }) => { + const disabled = fields.length === 0; + return ( +
    + + + +
    + + + + + + + DOI + URL + + +
    + + +
    + ); + })} + + + )} +
    + ); +}; diff --git a/client/modules/datafiles/src/projects/forms/_fields/UserSelect.tsx b/client/modules/datafiles/src/projects/forms/_fields/UserSelect.tsx new file mode 100644 index 0000000000..2496e9fb37 --- /dev/null +++ b/client/modules/datafiles/src/projects/forms/_fields/UserSelect.tsx @@ -0,0 +1,74 @@ +import { apiClient, useDebounceValue } from '@client/hooks'; +import { Select, SelectProps } from 'antd'; +import React, { useEffect, useMemo, useState } from 'react'; + +export type TProjectUser = { + fname: string; + lname: string; + username: string; + email: string; + inst: string; + role: string; +}; + +export const UserSelect: React.FC<{ + value?: TProjectUser[]; + onChange?: (value: TProjectUser[]) => void; + userRole?: string; + maxCount?: number; +}> = ({ value, onChange, userRole, maxCount }) => { + const initialOptions: SelectProps['options'] = useMemo( + () => + value?.map((u) => ({ + label: `${u.fname} ${u.lname} (${u.email})`, + value: JSON.stringify(u), + })) ?? [], + [value] + ); + const [data, setData] = useState(initialOptions); + + const [searchTerm, setSearchTerm] = useState(''); + const debouncedSearchTerm = useDebounceValue(searchTerm, 100); + + useEffect(() => { + if (!debouncedSearchTerm || debouncedSearchTerm.length < 3) { + setData([]); + return; + } + const controller = new AbortController(); + apiClient + .get<{ + result: Omit[]; + }>(`/api/users/project-lookup/?q=${debouncedSearchTerm}`, { + signal: controller.signal, + }) + .then((resp) => + resp.data.result.map((u) => ({ + label: `${u.fname} ${u.lname} (${u.email})`, + value: JSON.stringify({ ...u, role: userRole }), + })) + ) + .then((opts) => setData(opts)) + .catch((_) => setData([])); + return () => controller.abort(); + }, [debouncedSearchTerm, setData, userRole]); + + const changeCallback = (newValue: string[]) => { + onChange && onChange(newValue.map((v) => JSON.parse(v))); + setSearchTerm(''); + }; + + return ( +