diff --git a/client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts b/client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts new file mode 100644 index 0000000000..fad8672179 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/UseValidateEntitySelection.ts @@ -0,0 +1,32 @@ +import { useMutation } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +export type TPipelineValidationResult = { + errorType: string; + name: string; + title: string; + missing: string[]; +}; + +async function validateEntitySelection( + projectId: string, + entityUuids: string[] +) { + const res = await apiClient.post<{ result: TPipelineValidationResult[] }>( + `/api/projects/v2/${projectId}/entities/validate/`, + { entityUuids } + ); + return res.data; +} + +export function useValidateEntitySelection() { + return useMutation({ + mutationFn: ({ + projectId, + entityUuids, + }: { + projectId: string; + entityUuids: string[]; + }) => validateEntitySelection(projectId, entityUuids), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/index.ts b/client/modules/_hooks/src/datafiles/projects/index.ts index fcbb4f5691..32cdb6fa96 100644 --- a/client/modules/_hooks/src/datafiles/projects/index.ts +++ b/client/modules/_hooks/src/datafiles/projects/index.ts @@ -12,3 +12,7 @@ export { useRemoveEntityFromTree } from './useRemoveEntityFromTree'; export { useAddFileAssociation } from './useAddFileAssociation'; export { useRemoveFileAssociation } from './useRemoveFileAssociation'; export { useSetFileTags } from './useSetFileTags'; +export { usePatchEntityMetadata } from './usePatchEntityMetadata'; +export { usePatchProjectMetadata } from './usePatchProjectMetadata'; +export { useValidateEntitySelection } from './UseValidateEntitySelection'; +export type { TPipelineValidationResult } from './UseValidateEntitySelection'; diff --git a/client/modules/_hooks/src/datafiles/projects/types.ts b/client/modules/_hooks/src/datafiles/projects/types.ts index 0d1b65b6d8..7b8f0bc483 100644 --- a/client/modules/_hooks/src/datafiles/projects/types.ts +++ b/client/modules/_hooks/src/datafiles/projects/types.ts @@ -91,11 +91,14 @@ export type TBaseProjectValue = { dois: string[]; fileObjs: TFileObj[]; fileTags: TFileTag[]; + + license?: string; }; -type TEntityValue = { +export type TEntityValue = { title: string; description?: string; + projectId?: string; authors?: TProjectUser[]; fileObjs?: TFileObj[]; fileTags: TFileTag[]; @@ -116,3 +119,20 @@ export type TBaseProject = TProjectMeta & { export type TEntityMeta = TProjectMeta & { value: TEntityValue; }; + +export type TPreviewTreeData = { + name: string; + id: string; + uuid: string; + value: TEntityValue; + order: number; + children: TPreviewTreeData[]; +}; + +export type TTreeData = { + name: string; + id: string; + uuid: string; + order: number; + children: TTreeData[]; +}; diff --git a/client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts b/client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts new file mode 100644 index 0000000000..4a189a2012 --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/usePatchEntityMetadata.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function patchEntityMetadata( + entityUuid: string, + patchMetadata: Record +) { + // Replace undefined with null so that deleted values are unset instead of ignored. + Object.keys(patchMetadata).forEach((k) => { + if (patchMetadata[k] === undefined) { + patchMetadata[k] = null; + } + }); + const res = await apiClient.patch( + `/api/projects/v2/entities/${entityUuid}/`, + { patchMetadata } + ); + return res.data; +} + +export function usePatchEntityMetadata() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + entityUuid, + patchMetadata, + }: { + patchMetadata: Record; + entityUuid: string; + }) => patchEntityMetadata(entityUuid, patchMetadata), + onSuccess: () => + queryClient.invalidateQueries({ + queryKey: ['datafiles', 'projects', 'detail'], + }), + }); +} diff --git a/client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts b/client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts new file mode 100644 index 0000000000..8a6ad4722d --- /dev/null +++ b/client/modules/_hooks/src/datafiles/projects/usePatchProjectMetadata.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import apiClient from '../../apiClient'; + +async function patchProjectMetadata( + projectId: string, + patchMetadata: Record +) { + // Replace undefined with null so that deleted values are unset instead of ignored. + Object.keys(patchMetadata).forEach((k) => { + if (patchMetadata[k] === undefined) { + patchMetadata[k] = null; + } + }); + const res = await apiClient.patch(`/api/projects/v2/${projectId}/`, { + patchMetadata, + }); + return res.data; +} + +export function usePatchProjectMetadata(projectId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + patchMetadata, + }: { + patchMetadata: Record; + }) => patchProjectMetadata(projectId, patchMetadata), + 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 index 3035c52762..0995284d2d 100644 --- a/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts +++ b/client/modules/_hooks/src/datafiles/projects/useProjectDetail.ts @@ -1,12 +1,12 @@ import { useQuery } from '@tanstack/react-query'; import apiClient from '../../apiClient'; -import { TBaseProject, TEntityMeta, TFileTag } from './types'; +import { TBaseProject, TEntityMeta, TFileTag, TTreeData } from './types'; import { useMemo } from 'react'; type TProjectDetailResponse = { baseProject: TBaseProject; entities: TEntityMeta[]; - tree: unknown; + tree: TTreeData; }; async function getProjectDetail({ diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx b/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx index e6da9b8255..fe5598199a 100644 --- a/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx +++ b/client/modules/_hooks/src/datafiles/projects/useProjectPreview.tsx @@ -1,11 +1,11 @@ import { useQuery } from '@tanstack/react-query'; import apiClient from '../../apiClient'; -import { TBaseProject, TEntityMeta } from './types'; +import { TBaseProject, TEntityMeta, TPreviewTreeData } from './types'; type TProjectPreviewResponse = { baseProject: TBaseProject; entities: TEntityMeta[]; - tree: unknown; + tree: TPreviewTreeData; }; async function getProjectPreview({ diff --git a/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts b/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts index 4209944299..0bda571e8d 100644 --- a/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts +++ b/client/modules/_hooks/src/datafiles/publications/usePublicationDetail.ts @@ -1,9 +1,22 @@ import { useQuery } from '@tanstack/react-query'; import apiClient from '../../apiClient'; -import { TBaseProjectValue } from '../projects'; +import { TBaseProjectValue, TEntityValue } from '../projects'; + +export type TPublicationTree = { + name: string; + uuid: string; + id: string; + basePath: string; + value: T; + publicationDate: string; + status: string; + order: number; + version?: number; + children: TPublicationTree[]; +}; export type TPublicationDetailResponse = { - tree: unknown; + tree: TPublicationTree; baseProject: TBaseProjectValue; }; diff --git a/client/modules/_hooks/src/datafiles/useFileListing.ts b/client/modules/_hooks/src/datafiles/useFileListing.ts index 7d8b43ef50..a556e38668 100644 --- a/client/modules/_hooks/src/datafiles/useFileListing.ts +++ b/client/modules/_hooks/src/datafiles/useFileListing.ts @@ -45,6 +45,7 @@ type TFileListingHookArgs = { path: string; scheme: string; pageSize: number; + disabled?: boolean; }; type TFileListingPageParam = { @@ -58,6 +59,7 @@ function useFileListing({ path, scheme = 'private', pageSize = 100, + disabled = false, }: TFileListingHookArgs) { return useInfiniteQuery< FileListingResponse, @@ -78,6 +80,7 @@ function useFileListing({ signal, } ), + enabled: !disabled, getNextPageParam: (lastPage, allpages): TFileListingPageParam | null => { return lastPage.listing.length >= pageSize ? { page: allpages.length, nextPageToken: lastPage.nextPageToken } diff --git a/client/modules/_hooks/src/datafiles/usePathDisplayName.ts b/client/modules/_hooks/src/datafiles/usePathDisplayName.ts index c84b8c6cc2..da5d378a61 100644 --- a/client/modules/_hooks/src/datafiles/usePathDisplayName.ts +++ b/client/modules/_hooks/src/datafiles/usePathDisplayName.ts @@ -31,7 +31,7 @@ function _getPathDisplayName( return 'My Data'; } if (system === 'designsafe.storage.frontera.work' && path === usernamePath) { - return 'My Data (Work)'; + return 'HPC Work'; } return decodeURIComponent(path).split('/').slice(-1)[0] || 'Data Files'; diff --git a/client/modules/_hooks/src/datafiles/useSelectedFiles.ts b/client/modules/_hooks/src/datafiles/useSelectedFiles.ts index 27b5f371d9..2edab34bca 100644 --- a/client/modules/_hooks/src/datafiles/useSelectedFiles.ts +++ b/client/modules/_hooks/src/datafiles/useSelectedFiles.ts @@ -19,24 +19,48 @@ export function useSelectedFiles( const queryClient = useQueryClient(); const setSelectedFiles = useCallback( - (selection: TFileListing[]) => - queryClient.setQueryData(queryKey, selection), + (selection: TFileListing[]) => { + queryClient.setQueryData(queryKey, selection); + queryClient.invalidateQueries({ queryKey: ['rows-for-system'] }); + }, [queryKey, queryClient] ); - return { selectedFiles: selectedRowsQuery.data, setSelectedFiles }; + const unsetSelections = useCallback(() => { + queryClient.setQueriesData({ queryKey: ['selected-rows'] }, () => []); + queryClient.invalidateQueries({ queryKey: ['rows-for-system'] }); + }, [queryClient]); + + return { + selectedFiles: selectedRowsQuery.data, + setSelectedFiles, + unsetSelections, + }; } export function useSelectedFilesForSystem(api: string, system: string) { // Get all selected files matching a given system. // Used when multiple listings can be present in a single page, e.g. publications. - const queryKey = ['selected-rows', api, system]; + const queryClient = useQueryClient(); - const selections = queryClient.getQueriesData({ queryKey }); + /* + const selections = useMemo(() => { + const queryKey = ['selected-rows', api, system]; + return queryClient.getQueriesData({ queryKey }); + }, [api, system, queryClient]); + */ + + const { data: selections } = useQuery({ + queryKey: ['rows-for-system', api, system], + queryFn: () => { + const queryKey = ['selected-rows', api, system]; + return queryClient.getQueriesData({ queryKey }); + }, + }); const reducedSelections = useMemo(() => { const allSelections: TFileListing[] = []; - selections.forEach((s) => s[1] && allSelections.push(...s[1])); + (selections ?? []).forEach((s) => s[1] && allSelections.push(...s[1])); return allSelections; }, [selections]); return reducedSelections; diff --git a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css index dbf8538188..0f4239d8ad 100644 --- a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css +++ b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.module.css @@ -35,7 +35,6 @@ a.navLink:not(:global(.active)):hover > div { } .customUl { - border: 1px solid #e3e3e3; list-style-type: none; padding-left: 0; } diff --git a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx index 76b4756193..e624e8cfbe 100644 --- a/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx +++ b/client/modules/datafiles/src/AddFileFolder/AddFileFolder.tsx @@ -102,22 +102,23 @@ export const AddFileFolder: React.FC = () => { )} -
  • { - e.preventDefault(); - window.location.href = - 'https://www.designsafe-ci.org/rw/user-guides/data-transfer-guide/'; - }} - > - + +
  • diff --git a/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx b/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx index cc01ec4d8e..dda77dfcb4 100644 --- a/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx +++ b/client/modules/datafiles/src/DatafilesBreadcrumb/DatafilesBreadcrumb.tsx @@ -36,6 +36,7 @@ export const DatafilesBreadcrumb: React.FC< baseRoute: string; systemRoot: string; systemRootAlias?: string; + skipBreadcrumbs?: number; // Number of path elements to skip when generating breadcrumbs } & BreadcrumbProps > = ({ initialBreadcrumbs, @@ -43,11 +44,14 @@ export const DatafilesBreadcrumb: React.FC< baseRoute, systemRoot, systemRootAlias, + skipBreadcrumbs, ...props }) => { const breadcrumbItems = [ ...initialBreadcrumbs, - ...getPathRoutes(baseRoute, path, systemRoot, systemRootAlias), + ...getPathRoutes(baseRoute, path, systemRoot, systemRootAlias).slice( + skipBreadcrumbs ?? 0 + ), ]; return ( @@ -67,19 +71,32 @@ function isUserHomeSystem(system: string) { } export const BaseFileListingBreadcrumb: React.FC< - { api: string; system: string; path: string } & BreadcrumbProps -> = ({ api, system, path, ...props }) => { + { + api: string; + system: string; + path: string; + systemRootAlias?: string; + initialBreadcrumbs?: { title: string; path: string }[]; + } & BreadcrumbProps +> = ({ + api, + system, + path, + systemRootAlias, + initialBreadcrumbs = [], + ...props +}) => { const { user } = useAuthenticatedUser(); return ( ); diff --git a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css index 0d3f1691ea..5ceac66cca 100644 --- a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css +++ b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.module.css @@ -19,6 +19,12 @@ height: 100%; } +.modalRightPanel { + display: flex; + flex: 1; + flex-direction: column; +} + .destFilesSection { display: flex; flex: 1; diff --git a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx index ffa8e16615..95fae57d4d 100644 --- a/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx +++ b/client/modules/datafiles/src/DatafilesModal/CopyModal/CopyModal.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { TModalChildren } from '../DatafilesModal'; -import { Button, Modal, Table } from 'antd'; +import { Button, Modal, Select, Table } from 'antd'; import { useAuthenticatedUser, useFileCopy, @@ -14,6 +14,7 @@ import { import { BaseFileListingBreadcrumb } from '../../DatafilesBreadcrumb/DatafilesBreadcrumb'; import styles from './CopyModal.module.css'; import { toBytes } from '../../FileListing/FileListing'; +import { CopyModalProjectListing } from './CopyModalProjectListing'; const SelectedFilesColumns: TFileListingColumns = [ { @@ -31,14 +32,15 @@ const DestHeaderTitle: React.FC<{ api: string; system: string; path: string; -}> = ({ api, system, path }) => { + projectId?: string; +}> = ({ api, system, path, projectId }) => { const getPathName = usePathDisplayName(); return (    - {getPathName(api, system, path)} + {projectId || getPathName(api, system, path)} ); }; @@ -48,11 +50,19 @@ function getDestFilesColumns( system: string, path: string, mutationCallback: (path: string) => void, - navCallback: (path: string) => void + navCallback: (path: string) => void, + projectId?: string ): TFileListingColumns { return [ { - title: , + title: ( + + ), dataIndex: 'name', ellipsis: true, @@ -115,12 +125,58 @@ export const CopyModal: React.FC<{ [user] ); - const [dest, setDest] = useState(defaultDestParams); + const [dest, setDest] = useState<{ + destApi: string; + destSystem: string; + destPath: string; + destProjectId?: string; + }>(defaultDestParams); + const [showProjects, setShowProjects] = useState(false); const { destApi, destSystem, destPath } = dest; useEffect(() => setDest(defaultDestParams), [isModalOpen, defaultDestParams]); + const [dropdownValue, setDropdownValue] = useState('mydata'); + const dropdownCallback = (newValue: string) => { + setDropdownValue(newValue); + switch (newValue) { + case 'mydata': + setShowProjects(false); + setDest(defaultDestParams); + break; + case 'hpcwork': + setShowProjects(false); + setDest({ + destApi: 'tapis', + destSystem: 'designsafe.storage.frontera.work', + destPath: encodeURIComponent('/' + user?.username), + }); + break; + case 'myprojects': + setShowProjects(true); + break; + default: + setShowProjects(false); + setDest(defaultDestParams); + break; + } + }; + + const onProjectSelect = (uuid: string, projectId: string) => { + setShowProjects(false); + setDest({ + destApi: 'tapis', + destSystem: `project-${uuid}`, + destPath: '', + destProjectId: projectId, + }); + }; + const navCallback = useCallback( (path: string) => { + if (path === 'PROJECT_LISTING') { + setShowProjects(true); + return; + } const newPath = path.split('/').slice(-1)[0]; setDest({ ...dest, destPath: newPath }); }, @@ -148,9 +204,17 @@ export const CopyModal: React.FC<{ destSystem, destPath, (dPath: string) => mutateCallback(dPath), - navCallback + navCallback, + dest.destProjectId ), - [navCallback, destApi, destSystem, destPath, mutateCallback] + [ + navCallback, + destApi, + destSystem, + destPath, + dest.destProjectId, + mutateCallback, + ] ); return ( @@ -174,34 +238,68 @@ export const CopyModal: React.FC<{ scroll={{ y: '100%' }} /> -
    - { - return ( - - ); - }} +
    +