diff --git a/client/modules/_hooks/src/datafiles/projects/types.ts b/client/modules/_hooks/src/datafiles/projects/types.ts index 7b8f0bc483..2ffb9337c8 100644 --- a/client/modules/_hooks/src/datafiles/projects/types.ts +++ b/client/modules/_hooks/src/datafiles/projects/types.ts @@ -5,6 +5,7 @@ export type TProjectUser = { inst: string; role: 'pi' | 'co_pi' | 'team_member' | 'guest'; username?: string; + authorship?: boolean; }; export type TProjectAward = { @@ -92,6 +93,8 @@ export type TBaseProjectValue = { fileObjs: TFileObj[]; fileTags: TFileTag[]; + hazmapperMaps?: THazmapperMap[]; + license?: string; }; @@ -102,6 +105,23 @@ export type TEntityValue = { authors?: TProjectUser[]; fileObjs?: TFileObj[]; fileTags: TFileTag[]; + dateStart?: string; + dateEnd?: string; + location?: string; + event?: string; + facility?: TDropdownValue; + latitude?: string; + longitude?: string; + dois?: string[]; + referencedData: TReferencedWork[]; + relatedWork: TAssociatedProject[]; + + experimentType?: TDropdownValue; + equipmentType?: TDropdownValue; + procedureStart?: string; + procedureEnd?: string; + + simulationType?: TDropdownValue; }; export type TProjectMeta = { @@ -126,6 +146,7 @@ export type TPreviewTreeData = { uuid: string; value: TEntityValue; order: number; + publicationDate?: string; children: TPreviewTreeData[]; }; diff --git a/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts b/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts index 5d7aabe895..f4cbd83fe1 100644 --- a/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts +++ b/client/modules/_hooks/src/datafiles/projects/useProjectListing.ts @@ -1,4 +1,5 @@ import { useQuery } from '@tanstack/react-query'; +import { useSearchParams } from 'react-router-dom'; import apiClient from '../../apiClient'; import { TBaseProject } from './types'; @@ -10,25 +11,37 @@ export type TProjectListingResponse = { async function getProjectListing({ page = 1, limit = 100, + queryString, signal, }: { page: number; limit: number; + queryString?: string; signal: AbortSignal; }) { const resp = await apiClient.get( '/api/projects/v2', { signal, - params: { offset: (page - 1) * limit, limit }, + params: { offset: (page - 1) * limit, limit, q: queryString }, } ); return resp.data; } export function useProjectListing(page: number, limit: number) { + const [searchParams] = useSearchParams(); + const queryString = searchParams.get('q') ?? undefined; return useQuery({ - queryKey: ['datafiles', 'projects', 'listing', page, limit], - queryFn: ({ signal }) => getProjectListing({ page, limit, signal }), + queryKey: [ + 'datafiles', + 'projects', + 'listing', + page, + limit, + queryString ?? '', + ], + queryFn: ({ signal }) => + getProjectListing({ page, limit, queryString, signal }), }); } diff --git a/client/modules/_hooks/src/datafiles/useFileListing.ts b/client/modules/_hooks/src/datafiles/useFileListing.ts index a556e38668..3faa862e4d 100644 --- a/client/modules/_hooks/src/datafiles/useFileListing.ts +++ b/client/modules/_hooks/src/datafiles/useFileListing.ts @@ -1,6 +1,7 @@ import { useInfiniteQuery } from '@tanstack/react-query'; import apiClient from '../apiClient'; import { AxiosError } from 'axios'; +import { useSearchParams } from 'react-router-dom'; export type TFileListing = { system: string; @@ -28,13 +29,14 @@ async function getFileListing( limit: number = 100, page: number = 0, nextPageToken: string | undefined, + queryString: string | undefined, { signal }: { signal: AbortSignal } ) { const offset = page * limit; const res = await apiClient.get( `/api/datafiles/${api}/${scheme}/listing/${system}/${path}`, - { signal, params: { offset, limit, nextPageToken } } + { signal, params: { offset, limit, nextPageToken, q: queryString } } ); return res.data; } @@ -61,12 +63,21 @@ function useFileListing({ pageSize = 100, disabled = false, }: TFileListingHookArgs) { + const [searchParams] = useSearchParams(); + const queryString = searchParams.get('q'); return useInfiniteQuery< FileListingResponse, AxiosError<{ message?: string }> >({ initialPageParam: 0, - queryKey: ['datafiles', 'fileListing', api, system, path], + queryKey: [ + 'datafiles', + 'fileListing', + api, + system, + path, + queryString ?? '', + ], queryFn: ({ pageParam, signal }) => getFileListing( api, @@ -76,6 +87,7 @@ function useFileListing({ pageSize, (pageParam as TFileListingPageParam).page, (pageParam as TFileListingPageParam).nextPageToken, + queryString ?? undefined, { signal, } diff --git a/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx index 1280f4f436..31b6341d67 100644 --- a/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx +++ b/client/modules/datafiles/src/DatafilesToolbar/DatafilesToolbar.tsx @@ -24,7 +24,9 @@ const ToolbarButton: React.FC = (props) => { ); }; -export const DatafilesToolbar: React.FC = () => { +export const DatafilesToolbar: React.FC<{ searchInput?: React.ReactNode }> = ({ + searchInput, +}) => { const { api, system, scheme, path } = useFileListingRouteParams(); const { selectedFiles } = useSelectedFiles(api, system, path); const { user } = useAuthenticatedUser(); @@ -45,7 +47,7 @@ export const DatafilesToolbar: React.FC = () => { return (
- (search bar goes here) +
{searchInput ?? null}
diff --git a/client/modules/datafiles/src/FileListing/FileListing.tsx b/client/modules/datafiles/src/FileListing/FileListing.tsx index 252286e60d..07a1e22a4f 100644 --- a/client/modules/datafiles/src/FileListing/FileListing.tsx +++ b/client/modules/datafiles/src/FileListing/FileListing.tsx @@ -24,8 +24,16 @@ export const FileListing: React.FC< system: string; path?: string; scheme?: string; + baseRoute?: string; } & Omit -> = ({ api, system, path = '', scheme = 'private', ...tableProps }) => { +> = ({ + api, + system, + path = '', + scheme = 'private', + baseRoute, + ...tableProps +}) => { // Base file listing for use with My Data/Community Data const [previewModalState, setPreviewModalState] = useState<{ isOpen: boolean; @@ -43,7 +51,7 @@ export const FileListing: React.FC< record.type === 'dir' ? ( new Date(d).toLocaleString(), }, ], - [setPreviewModalState] + [setPreviewModalState, baseRoute] ); return ( diff --git a/client/modules/datafiles/src/projects/BaseProjectDetails.module.css b/client/modules/datafiles/src/projects/BaseProjectDetails.module.css index 10737750ae..8a66681587 100644 --- a/client/modules/datafiles/src/projects/BaseProjectDetails.module.css +++ b/client/modules/datafiles/src/projects/BaseProjectDetails.module.css @@ -10,3 +10,7 @@ -webkit-box-orient: vertical; overflow: hidden; } + +.prj-row td { + padding: 2px 0px; +} diff --git a/client/modules/datafiles/src/projects/BaseProjectDetails.tsx b/client/modules/datafiles/src/projects/BaseProjectDetails.tsx index 2de96ae8a1..8020e940d5 100644 --- a/client/modules/datafiles/src/projects/BaseProjectDetails.tsx +++ b/client/modules/datafiles/src/projects/BaseProjectDetails.tsx @@ -1,8 +1,8 @@ import React, { useEffect, useState, useCallback } from 'react'; -import { TBaseProjectValue } from '@client/hooks'; +import { TBaseProjectValue, TProjectUser } from '@client/hooks'; import styles from './BaseProjectDetails.module.css'; -import { Button } from 'antd'; +import { Button, Col, Popover, Row, Tooltip } from 'antd'; export const DescriptionExpander: React.FC = ({ children, @@ -49,49 +49,173 @@ export const DescriptionExpander: React.FC = ({ ); }; +export const LicenseDisplay: React.FC<{ licenseType: string }> = ({ + licenseType, +}) => { + const ENTITY_ICON_MAP: Record = { + 'GNU General Public License': 'curation-gpl', + 'Open Data Commons Attribution': 'curation-odc', + 'Open Data Commons Public Domain Dedication': 'curation-odc', + 'Creative Commons Attribution': 'curation-cc-share', + 'Creative Commons Public Domain Dedication': 'curation-cc-zero', + '3-Clause BSD License': 'curation-3bsd', + }; + return ( +
+ +   + {licenseType} +
+ ); +}; + +export const UsernamePopover: React.FC<{ user: TProjectUser }> = ({ user }) => { + const content = ( +
+ + Name + + + {user.fname} {user.lname} + + + + + Email + + {user.email} + + + + Institution + + {user.inst} + + +
+ ); + return ( + {`${user.lname}, ${user.fname}`} + } + > + + + ); +}; + +const projectTypeMapping = { + field_recon: 'Field research', + other: 'Other', + experimental: 'Experimental', + simulation: 'Simulation', + hybrid_simulation: 'Hybrid Simulation', + field_reconnaissance: 'Field Reconaissance', + None: 'None', +}; + export const BaseProjectDetails: React.FC<{ projectValue: TBaseProjectValue; -}> = ({ projectValue }) => { + publicationDate?: string; +}> = ({ projectValue, publicationDate }) => { const pi = projectValue.users.find((u) => u.role === 'pi'); const coPis = projectValue.users.filter((u) => u.role === 'co_pi'); + const projectType = [ + projectTypeMapping[projectValue.projectType], + ...(projectValue.frTypes?.map((t) => t.name) ?? []), + ].join(' | '); return (
- +
- - - - - - - - - - - - - - - - - - + {pi && projectValue.projectType !== 'other' && ( + + + + + )} + {coPis.length > 0 && projectValue.projectType !== 'other' && ( + + + + + )} + {projectValue.authors.length > 0 && + projectValue.projectType === 'other' && ( + + + + + )} + {projectValue.projectType !== 'other' && ( + + + + + )} + {(projectValue.dataTypes?.length ?? 0) > 0 && ( + + + + + )} + + - + {publicationDate && ( + + + + + )} + - - - - - - - - - + {(projectValue.nhEvents?.length ?? 0) > 0 && ( + + + + + )} + {(projectValue.awardNumbers?.length ?? 0) > 0 && ( + + + + + )} + {(projectValue.associatedProjects?.length ?? 0) > 0 && ( + + + + + )} + + {(projectValue.hazmapperMaps?.length ?? 0) > 0 && ( + + + + + )} + + {projectValue.projectType === 'other' && projectValue.license && ( + + + + + )}
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
PI + +
Co-PIs + {coPis.map((u, i) => ( + <> + + {i !== coPis.length - 1 && '; '} + + ))} +
Authors + {projectValue.authors.map((u, i) => ( + <> + + {i !== projectValue.authors.length - 1 && '; '} + + ))} +
Project Type{projectType}
Data Type(s) + {projectValue.dataTypes?.map((d) => d.name).join(', ')} +
Natural Hazard Type(s) {`${projectValue.nhTypes .map((t) => t.name) .join(', ')}`}
Date of Publication + {new Date(publicationDate).toISOString().split('T')[0]} +
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(' | ')}{' '} -
- ))} -
Event(s) + {projectValue.nhEvents.map((evt) => ( +
+ {evt.eventName} | {evt.location} |{' '} + {new Date(evt.eventStart).toISOString().split('T')[0]} + {' ― '} + { + new Date(evt.eventEnd ?? evt.eventStart) + .toISOString() + .split('T')[0] + }{' '} + |{' '} + + Lat {evt.latitude} long {evt.longitude} + +
+ ))} +
Awards + {projectValue.awardNumbers.map((t) => ( +
+ {[t.name, t.number, t.fundingSource] + .filter((v) => !!v) + .join(' | ')}{' '} +
+ ))} +
Related Work + {projectValue.associatedProjects.map((assoc) => ( + + ))} +
Keywords {projectValue.keywords.join(', ')}
Hazmapper Maps + {(projectValue.hazmapperMaps ?? []).map((m) => ( +
+ {m.name}  + + + + + +
+ ))} +
License + +
diff --git a/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx b/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx index df43e754d9..f4fd8dc9d4 100644 --- a/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx +++ b/client/modules/datafiles/src/projects/ProjectCitation/ProjectCitation.tsx @@ -43,9 +43,10 @@ export const PublishedCitation: React.FC<{ ? `${author.lname}, ${author.fname[0]}.` : `${author.fname[0]}. ${author.lname}` ) - .join(', ')} - . "{entityDetails.value.title}", in {data.baseProject.title}. - DesignSafe-CI. (DOI will appear after publication) + .join(', ')}{' '} + ({new Date(entityDetails.publicationDate).getFullYear()}). " + {entityDetails.value.title}", in {data.baseProject.title}. + DesignSafe-CI. ({entityDetails.value.dois && entityDetails.value.dois[0]})
); }; diff --git a/client/modules/datafiles/src/projects/ProjectListing.tsx b/client/modules/datafiles/src/projects/ProjectListing.tsx index 4e009b4012..ab01a2a5f6 100644 --- a/client/modules/datafiles/src/projects/ProjectListing.tsx +++ b/client/modules/datafiles/src/projects/ProjectListing.tsx @@ -23,7 +23,10 @@ const columns: TableColumnsType = [ }, title: 'Principal Investigator', }, - { render: (_, record) => record.lastUpdated, title: 'Last Modified' }, + { + render: (_, record) => new Date(record.lastUpdated).toLocaleString(), + title: 'Last Modified', + }, ]; export const ProjectListing: React.FC = () => { diff --git a/client/modules/datafiles/src/projects/ProjectPreview/ProjectPreview.tsx b/client/modules/datafiles/src/projects/ProjectPreview/ProjectPreview.tsx index 09208952cd..947b98ed8a 100644 --- a/client/modules/datafiles/src/projects/ProjectPreview/ProjectPreview.tsx +++ b/client/modules/datafiles/src/projects/ProjectPreview/ProjectPreview.tsx @@ -18,6 +18,7 @@ import { TFileListingColumns, } from '../../FileListing/FileListingTable/FileListingTable'; import { NavLink } from 'react-router-dom'; +import { PublishedEntityDetails } from '../PublishedEntityDetails'; const columns: TFileListingColumns = [ { @@ -97,6 +98,7 @@ function RecursiveTree({ export const PublishedEntityDisplay: React.FC<{ projectId: string; preview?: boolean; + license?: string; treeData: TPreviewTreeData; defaultOpen?: boolean; defaultOpenChildren?: boolean; @@ -104,6 +106,7 @@ export const PublishedEntityDisplay: React.FC<{ projectId, preview, treeData, + license, defaultOpen = false, defaultOpenChildren = false, }) => { @@ -159,13 +162,33 @@ export const PublishedEntityDisplay: React.FC<{ )}
), - children: (sortedChildren ?? []).map((child) => ( - - )), + children: ( + <> + + {(treeData.value.fileObjs?.length ?? 0) > 0 && ( + + )} + {(sortedChildren ?? []).map((child) => ( + + ))} + + ), }, ]} /> @@ -187,15 +210,17 @@ export const ProjectPreview: React.FC<{ projectId: string }> = ({ return (
- {sortedChildren.map((child, idx) => ( - - ))} + {sortedChildren + .filter((child) => child.name !== 'designsafe.project') + .map((child, idx) => ( + + ))}
); }; @@ -219,9 +244,13 @@ export const PublicationView: React.FC<{ return (
{sortedChildren - .filter((child) => child.version === version) + .filter( + (child) => + child.version === version && child.name !== 'designsafe.project' + ) .map((child, idx) => ( = ({ entityValue, publicationDate, license }) => { + return ( +
+ + + + + + + {entityValue.event && ( + + + + + )} + + {entityValue.dateStart && ( + + + + + )} + + {entityValue.simulationType && ( + + + + + )} + + {(entityValue.authors ?? []).length > 0 && ( + + + + + )} + + {entityValue.facility && ( + + + + + )} + + {entityValue.experimentType && ( + + + + + )} + + {entityValue.equipmentType && ( + + + + + )} + + {entityValue.procedureStart && ( + + + + + )} + {entityValue.location && ( + + + + + )} + + {(entityValue.relatedWork?.length ?? 0) > 0 && ( + + + + + )} + {(entityValue.referencedData?.length ?? 0) > 0 && ( + + + + + )} + + {publicationDate && ( + + + + + )} + + {entityValue.dois && entityValue.dois[0] && ( + + + + + )} + + {license && ( + + + + + )} + +
Event{entityValue.event}
Date(s) + {new Date(entityValue.dateStart).toISOString().split('T')[0]} + {entityValue.dateEnd && ( + + {' ― '} + {new Date(entityValue.dateEnd).toISOString().split('T')[0]} + + )} +
Facility + {entityValue.simulationType?.name} +
Authors + {entityValue.authors + ?.filter((a) => a.authorship !== false) + .map((u, i) => ( + <> + + {i !== + (entityValue.authors?.filter( + (a) => a.authorship !== false + ).length ?? 0) - + 1 && '; '} + + ))} +
Experiment Type + {entityValue.facility?.name} +
Facility + {entityValue.experimentType?.name} +
Equipment Type + {entityValue.equipmentType?.name} +
Date of Experiment + { + new Date(entityValue.procedureStart) + .toISOString() + .split('T')[0] + } + {entityValue.procedureEnd && ( + + {' ― '} + { + new Date(entityValue.procedureEnd) + .toISOString() + .split('T')[0] + } + + )} +
Site Location + {entityValue.location} |{' '} + + Lat {entityValue.latitude} long {entityValue.longitude} + +
Related Work + {entityValue.relatedWork.map((assoc) => ( + + ))} +
Related Work + {entityValue.referencedData.map((ref) => ( +
+ {ref.hrefType && `${ref.hrefType} | `} + + {ref.title} + +
+ ))} +
Date Published + {new Date(publicationDate).toISOString().split('T')[0]} +
DOI{entityValue.dois[0]}
License + +
+ + Description: + {entityValue.description} + +
+ ); +}; diff --git a/client/modules/datafiles/src/publications/PublicationSearchSidebar/PublicationSearchSidebar.module.css b/client/modules/datafiles/src/publications/PublicationSearchSidebar/PublicationSearchSidebar.module.css new file mode 100644 index 0000000000..04ea748c60 --- /dev/null +++ b/client/modules/datafiles/src/publications/PublicationSearchSidebar/PublicationSearchSidebar.module.css @@ -0,0 +1,16 @@ +.projectTypeOptions { + border: 1px solid #dddddd; + padding: 15px 10px; +} + +.sidebarLabel { + font-weight: normal; +} + +.checkboxRow { + margin-bottom: 10px; +} + +.sidebarSelect { + width: 100%; +} diff --git a/client/modules/datafiles/src/publications/PublicationSearchSidebar/PublicationSearchSidebar.tsx b/client/modules/datafiles/src/publications/PublicationSearchSidebar/PublicationSearchSidebar.tsx new file mode 100644 index 0000000000..a307cdf699 --- /dev/null +++ b/client/modules/datafiles/src/publications/PublicationSearchSidebar/PublicationSearchSidebar.tsx @@ -0,0 +1,217 @@ +import { Checkbox, Select } from 'antd'; +import React from 'react'; +import * as dropdownOptions from '../../projects/forms/ProjectFormDropdowns'; +import styles from './PublicationSearchSidebar.module.css'; +import { useSearchParams } from 'react-router-dom'; +export const PublicationSearchSidebar: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + + const toggleProjectTypeFilter = (projectType: string) => { + const selectedTypes = searchParams.getAll('project-type'); + const newSearchParams = new URLSearchParams(searchParams); + + if (selectedTypes.includes(projectType)) { + newSearchParams.delete('project-type', projectType); + setSearchParams(newSearchParams, { replace: true }); + } else { + newSearchParams.append('project-type', projectType); + setSearchParams(newSearchParams, { replace: true }); + } + }; + + const setSearchParam = (key: string, value?: string) => { + const newSearchParams = new URLSearchParams(searchParams); + if (value) { + newSearchParams.set(key, value); + } else { + newSearchParams.delete(key); + } + if (key === 'facility') { + newSearchParams.delete('experiment-type'); + } + setSearchParams(newSearchParams); + }; + + const currentYear = new Date(Date.now()).getUTCFullYear(); + //Show events going back to 2015 + const datesInRange = []; + for (let i = currentYear; i >= 2015; i--) { + datesInRange.push(i); + } + const yearOptions = datesInRange.map((y) => ({ label: y, value: y })); + + return ( +
+
+ + + setSearchParam('experiment-type', v)} + options={ + searchParams.get('facility') + ? dropdownOptions.experimentTypeOptions[ + searchParams.get('facility') ?? '' + ] + : [] + } + style={{ width: '100%' }} + placeholder="All Types" + /> +
+ +
+
+ toggleProjectTypeFilter('simulation')} + /> +   + Simulation +
+ + + setSearchParam('fr-type', v)} + style={{ width: '100%', marginBottom: '5px' }} + placeholder="All Types" + /> + +
+ + setSearchParam('hyb-sim-type', v)} + allowClear + style={{ width: '100%' }} + placeholder="All Types" + /> +
+
+
+ {' '} +
+ toggleProjectTypeFilter('other')} + /> +   + Other +
+
+ + + + +
+ +   + setSearchParam('pub-year', v)} + /> +
+ + +
+ ); +}; diff --git a/client/modules/datafiles/src/publications/PublishedListing/PublishedListing.tsx b/client/modules/datafiles/src/publications/PublishedListing/PublishedListing.tsx index 011da2940d..7afed2d55d 100644 --- a/client/modules/datafiles/src/publications/PublishedListing/PublishedListing.tsx +++ b/client/modules/datafiles/src/publications/PublishedListing/PublishedListing.tsx @@ -7,7 +7,7 @@ const columns: TableColumnsType = [ { render: (_, record) => record.projectId, title: 'Project ID', - width: '10%', + width: '100px', }, { render: (_, record) => {record.title}, @@ -19,8 +19,13 @@ const columns: TableColumnsType = [ return `${record.pi?.fname} ${record.pi?.lname}`; }, title: 'Principal Investigator', + ellipsis: true, + }, + { + title: 'Publication Date', + ellipsis: true, + render: (_, record) => new Date(record.created).toLocaleDateString(), }, - { render: (_, record) => record.created, title: 'Publication Date' }, ]; export const PublishedListing: React.FC = () => { @@ -34,7 +39,7 @@ export const PublishedListing: React.FC = () => { loading={isLoading} columns={columns} style={{ height: '100%' }} - scroll={{ y: '100%' }} + scroll={{ y: '100%', x: 500 }} rowKey={(row) => row.projectId} pagination={{ total: data?.total, diff --git a/client/modules/datafiles/src/publications/index.ts b/client/modules/datafiles/src/publications/index.ts index d4808b5e8c..796ceebc9f 100644 --- a/client/modules/datafiles/src/publications/index.ts +++ b/client/modules/datafiles/src/publications/index.ts @@ -1 +1,3 @@ export { PublishedListing } from './PublishedListing/PublishedListing'; +export { PublicationSearchSidebar } from './PublicationSearchSidebar/PublicationSearchSidebar'; +export { PublicationSearchToolbar } from './PublicationSearchToolbar/PublicationSearchToolbar'; diff --git a/client/src/datafiles/layouts/FileListingLayout.tsx b/client/src/datafiles/layouts/FileListingLayout.tsx index 4dbcc432d5..ec873cce61 100644 --- a/client/src/datafiles/layouts/FileListingLayout.tsx +++ b/client/src/datafiles/layouts/FileListingLayout.tsx @@ -4,11 +4,38 @@ import { FileListing, } from '@client/datafiles'; import { useAuthenticatedUser, useFileListingRouteParams } from '@client/hooks'; -import { Layout } from 'antd'; +import { Button, Form, Input, Layout } from 'antd'; import React from 'react'; -import { Link, Navigate } from 'react-router-dom'; +import { Link, Navigate, useSearchParams } from 'react-router-dom'; import styles from './layout.module.css'; +const FileListingSearchBar = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const onSubmit = (queryString: string) => { + const newSearchParams = searchParams; + if (queryString) { + newSearchParams.set('q', queryString); + } else { + newSearchParams.delete('q'); + } + + setSearchParams(newSearchParams); + }; + return ( +
onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + + + + +
+ ); +}; + export const FileListingLayout: React.FC = () => { const { api, path, scheme, system } = useFileListingRouteParams(); const { user } = useAuthenticatedUser(); @@ -22,7 +49,7 @@ export const FileListingLayout: React.FC = () => { user?.username && !path && api === 'tapis' && isUserHomeSystem; return ( - + } /> {true && ( = ({ projectId, @@ -124,7 +125,9 @@ const PublishableEntityButton: React.FC<{ projectId: string }> = ({ export const ProjectCurationLayout: React.FC = () => { const { projectId, path } = useParams(); + const { data } = useProjectDetail(projectId ?? ''); if (!projectId) return null; + if (!data) return
loading...
; return (
@@ -163,6 +166,20 @@ export const ProjectCurationLayout: React.FC = () => {
+ { + return ( + + {obj.title} + + ); + }} + />
); diff --git a/client/src/datafiles/layouts/projects/ProjectDetailLayout.tsx b/client/src/datafiles/layouts/projects/ProjectDetailLayout.tsx index 627fe0a488..95ac9ddbfb 100644 --- a/client/src/datafiles/layouts/projects/ProjectDetailLayout.tsx +++ b/client/src/datafiles/layouts/projects/ProjectDetailLayout.tsx @@ -1,8 +1,40 @@ import React from 'react'; -import { Outlet, useParams } from 'react-router-dom'; -import { BaseProjectDetails, ProjectTitleHeader } from '@client/datafiles'; +import { Outlet, useParams, useSearchParams } from 'react-router-dom'; +import { + BaseProjectDetails, + DatafilesToolbar, + ProjectTitleHeader, +} from '@client/datafiles'; +import { Button, Form, Input } from 'antd'; import { useProjectDetail } from '@client/hooks'; +const FileListingSearchBar = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const onSubmit = (queryString: string) => { + const newSearchParams = searchParams; + if (queryString) { + newSearchParams.set('q', queryString); + } else { + newSearchParams.delete('q'); + } + + setSearchParams(newSearchParams); + }; + return ( +
onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + + + + +
+ ); +}; + export const ProjectDetailLayout: React.FC = () => { const { projectId } = useParams(); const { data } = useProjectDetail(projectId ?? ''); @@ -10,6 +42,7 @@ export const ProjectDetailLayout: React.FC = () => { return (
+ } /> diff --git a/client/src/datafiles/layouts/projects/ProjectListingLayout.tsx b/client/src/datafiles/layouts/projects/ProjectListingLayout.tsx index 5cf9ab8304..807ec42ffe 100644 --- a/client/src/datafiles/layouts/projects/ProjectListingLayout.tsx +++ b/client/src/datafiles/layouts/projects/ProjectListingLayout.tsx @@ -1,11 +1,40 @@ -import { ProjectListing } from '@client/datafiles'; -import { Layout } from 'antd'; +import { DatafilesToolbar, ProjectListing } from '@client/datafiles'; +import { Button, Form, Input, Layout } from 'antd'; import React from 'react'; +import { useSearchParams } from 'react-router-dom'; + +const ProjectListingSearchBar = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const onSubmit = (queryString: string) => { + const newSearchParams = searchParams; + if (queryString) { + newSearchParams.set('q', queryString); + } else { + newSearchParams.delete('q'); + } + + setSearchParams(newSearchParams); + }; + return ( +
onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + + + + +
+ ); +}; + export const ProjectListingLayout: React.FC = () => { return ( -
Placeholder for the project listing searchbar
+ } />
diff --git a/client/src/datafiles/layouts/projects/ProjectWorkdirLayout.tsx b/client/src/datafiles/layouts/projects/ProjectWorkdirLayout.tsx index 4f64796dca..0fd579cca7 100644 --- a/client/src/datafiles/layouts/projects/ProjectWorkdirLayout.tsx +++ b/client/src/datafiles/layouts/projects/ProjectWorkdirLayout.tsx @@ -19,7 +19,7 @@ export const ProjectWorkdirLayout: React.FC = () => { initialBreadcrumbs={[]} path={path ?? ''} baseRoute={`/projects/${projectId}/workdir`} - systemRootAlias={projectId} + systemRootAlias={data.baseProject.value.projectId} systemRoot="" itemRender={(obj) => { return ( diff --git a/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx b/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx index 26c1b9ec5a..dbbf61ad24 100644 --- a/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx +++ b/client/src/datafiles/layouts/published/PublishedDetailLayout.tsx @@ -1,10 +1,38 @@ -import { BaseProjectDetails } from '@client/datafiles'; +import { BaseProjectDetails, DatafilesToolbar } from '@client/datafiles'; import { usePublicationDetail } from '@client/hooks'; import React, { useEffect } from 'react'; -import { Outlet, useParams, useSearchParams } from 'react-router-dom'; +import { Button, Form, Input } from 'antd'; +import { Navigate, Outlet, useParams, useSearchParams } from 'react-router-dom'; + +const FileListingSearchBar = () => { + const [searchParams, setSearchParams] = useSearchParams(); + const onSubmit = (queryString: string) => { + const newSearchParams = searchParams; + if (queryString) { + newSearchParams.set('q', queryString); + } else { + newSearchParams.delete('q'); + } + + setSearchParams(newSearchParams); + }; + return ( +
onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + + + + +
+ ); +}; export const PublishedDetailLayout: React.FC = () => { - const { projectId } = useParams(); + const { projectId, path } = useParams(); const [searchParams, setSearchParams] = useSearchParams(); const { data } = usePublicationDetail(projectId ?? ''); @@ -19,13 +47,34 @@ export const PublishedDetailLayout: React.FC = () => { if (!projectId || !data) return null; + if (searchParams.get('q') && !path) { + return ( + + ); + } + + const publicationDate = data.tree.children.find( + (c) => c.value.projectId === projectId + )?.publicationDate; + return (
-
+ } /> +
{data.baseProject.projectId} |  {data.baseProject.title}
- +
); diff --git a/client/src/datafiles/layouts/published/PublishedEntityListingLayout.tsx b/client/src/datafiles/layouts/published/PublishedEntityListingLayout.tsx index 527707554e..46298a5fa5 100644 --- a/client/src/datafiles/layouts/published/PublishedEntityListingLayout.tsx +++ b/client/src/datafiles/layouts/published/PublishedEntityListingLayout.tsx @@ -1,4 +1,4 @@ -import { PublicationView } from '@client/datafiles'; +import { FileListing, PublicationView } from '@client/datafiles'; import { usePublicationDetail } from '@client/hooks'; import React from 'react'; import { useParams } from 'react-router-dom'; @@ -12,6 +12,17 @@ export const PublishedEntityListingLayout: React.FC = () => { return (
+ {['other', 'field_reconnaissance'].includes( + data.baseProject.projectType + ) && ( + + )}
); }; diff --git a/client/src/datafiles/layouts/published/PublishedListingLayout.tsx b/client/src/datafiles/layouts/published/PublishedListingLayout.tsx index d334db3e2a..c53a02719c 100644 --- a/client/src/datafiles/layouts/published/PublishedListingLayout.tsx +++ b/client/src/datafiles/layouts/published/PublishedListingLayout.tsx @@ -1,14 +1,40 @@ -import { PublishedListing } from '@client/datafiles'; +import { + PublicationSearchSidebar, + PublicationSearchToolbar, + PublishedListing, +} from '@client/datafiles'; import { Layout } from 'antd'; import React from 'react'; export const PublishedListingLayout: React.FC = () => { return ( - -
Placeholder for the publication listing searchbar
-
- + +
+
+ + +
+ +
+
+ + + +
); }; diff --git a/designsafe/apps/api/projects_v2/migration_utils/project_db_ingest.py b/designsafe/apps/api/projects_v2/migration_utils/project_db_ingest.py index 90ead81c4d..6d968fed4e 100644 --- a/designsafe/apps/api/projects_v2/migration_utils/project_db_ingest.py +++ b/designsafe/apps/api/projects_v2/migration_utils/project_db_ingest.py @@ -2,6 +2,7 @@ from datetime import datetime, timezone from pydantic import ValidationError +from elasticsearch_dsl import Q import networkx as nx from designsafe.apps.api.projects_v2.models.project_metadata import ProjectMetadata from designsafe.apps.api.projects_v2.migration_utils.graph_constructor import ( @@ -172,7 +173,6 @@ def ingest_publications(): """Ingest Elasticsearch-based publications into the db""" all_pubs = iterate_pubs() for pub in all_pubs: - print(pub["projectId"]) try: pub_graph = combine_pub_versions(pub["projectId"]) latest_version: int = IndexedPublication.max_revision(pub["projectId"]) or 1 @@ -204,3 +204,45 @@ def ingest_publications(): except ValidationError as exc: print(pub["projectId"]) print(exc) + + +def ingest_tombstones(): + """Ingest Elasticsearch tombstones into the db""" + + all_pubs = ( + IndexedPublication.search().filter(Q("term", status="tombstone")).execute().hits + ) + print(all_pubs) + for pub in all_pubs: + try: + pub_graph = combine_pub_versions(pub["projectId"]) + latest_version: int = IndexedPublication.max_revision(pub["projectId"]) or 1 + pub_base = next( + ( + pub_graph.nodes[node_id]["value"] + for node_id in pub_graph + if ( + pub_graph.nodes[node_id]["uuid"] == pub["project"]["uuid"] + and pub_graph.nodes[node_id].get("version", latest_version) + == latest_version + ) + ), + None, + ) + if not pub_base: + raise ValueError("No pub base") + pub_graph_json = nx.node_link_data(pub_graph) + Publication.objects.update_or_create( + project_id=pub["projectId"], + defaults={ + "is_published": False, + "created": datetime.fromisoformat(pub["created"]).replace( + tzinfo=timezone.utc + ), + "tree": pub_graph_json, + "value": pub_base, + }, + ) + except ValidationError as exc: + print(pub["projectId"]) + print(exc) diff --git a/designsafe/apps/api/projects_v2/migration_utils/publication_transforms.py b/designsafe/apps/api/projects_v2/migration_utils/publication_transforms.py index ce57fc86f0..0f794dfa12 100644 --- a/designsafe/apps/api/projects_v2/migration_utils/publication_transforms.py +++ b/designsafe/apps/api/projects_v2/migration_utils/publication_transforms.py @@ -287,6 +287,13 @@ def transform_entity(entity: dict, base_pub_meta: dict, base_path: str): fixed_authors = list(map(convert_v2_user, entity["authors"])) entity["value"]["authors"] = sorted(fixed_authors, key=lambda a: a["order"]) + tombstone_uuids = base_pub_meta.get("tombstone", []) + if entity["uuid"] in tombstone_uuids: + entity["value"]["tombstone"] = True + tombstone_message = base_pub_meta.get("tombstoneMessage", None) + if tombstone_message: + entity["value"]["tombstoneMessage"] = tombstone_message + old_tags = entity["value"].get("tags", None) if old_tags: new_style_tags = convert_legacy_tags(entity) @@ -316,6 +323,20 @@ def transform_entity(entity: dict, base_pub_meta: dict, base_path: str): entity["value"]["fileObjs"] = new_file_objs else: path_mapping = {} - validated_model = model.model_validate(entity["value"]) + + if getattr(validated_model, "project_type", None) == "other": + # Type Other doesn't include emails/institutions in team order + for author in validated_model.authors: + user = next( + (u for u in validated_model.users if u.username == author.name), None + ) + if user: + author.email = user.email + author.inst = user.inst + if schema_version == 1: + validated_model.authors = [ + u for u in validated_model.users if u.fname != "N/A" + ] + return validated_model.model_dump(), path_mapping diff --git a/designsafe/apps/api/projects_v2/schema_models/base.py b/designsafe/apps/api/projects_v2/schema_models/base.py index c6a96cd43a..ad39660b79 100644 --- a/designsafe/apps/api/projects_v2/schema_models/base.py +++ b/designsafe/apps/api/projects_v2/schema_models/base.py @@ -131,6 +131,9 @@ class BaseProject(MetadataModel): coverage_temporal: Optional[str] = None lat_long_name: Optional[str] = None + tombstone: bool = False + tombstone_message: Optional[str] = None + def construct_users(self) -> list[ProjectUser]: """Fill in missing user information from the database.""" users = [] diff --git a/designsafe/apps/api/projects_v2/schema_models/experimental.py b/designsafe/apps/api/projects_v2/schema_models/experimental.py index 9b457ae70d..491ef6ce48 100644 --- a/designsafe/apps/api/projects_v2/schema_models/experimental.py +++ b/designsafe/apps/api/projects_v2/schema_models/experimental.py @@ -64,6 +64,8 @@ class Experiment(MetadataModel): project: list[str] = [] dois: list[str] = [] + tombstone: bool = False + @model_validator(mode="after") def handle_other(self): """Use values of XXX_other fields to fill in dropdown values.""" diff --git a/designsafe/apps/api/projects_v2/schema_models/field_recon.py b/designsafe/apps/api/projects_v2/schema_models/field_recon.py index b8b62b6af3..f4e48fa646 100644 --- a/designsafe/apps/api/projects_v2/schema_models/field_recon.py +++ b/designsafe/apps/api/projects_v2/schema_models/field_recon.py @@ -44,6 +44,7 @@ class Mission(MetadataModel): authors: Annotated[list[ProjectUser], BeforeValidator(handle_legacy_authors)] = [] project: list[str] = [] dois: list[str] = [] + tombstone: bool = False # Deprecate these later facility: Optional[DropdownValue] = None @@ -76,6 +77,8 @@ class FieldReconReport(MetadataModel): missions: list[str] = Field(default=[], exclude=True) referenced_datas: list[ReferencedWork] = Field(default=[], exclude=True) + tombstone: bool = False + class Instrument(MetadataModel): """model for instruments used in field recon projects.""" diff --git a/designsafe/apps/api/projects_v2/schema_models/hybrid_sim.py b/designsafe/apps/api/projects_v2/schema_models/hybrid_sim.py index 6affbb6757..99f1f024ed 100644 --- a/designsafe/apps/api/projects_v2/schema_models/hybrid_sim.py +++ b/designsafe/apps/api/projects_v2/schema_models/hybrid_sim.py @@ -39,6 +39,8 @@ class HybridSimulation(MetadataModel): facility: Optional[DropdownValue] = None + tombstone: bool = False + @model_validator(mode="after") def handle_other(self): """Use values of XXX_other fields to fill in dropdown values.""" diff --git a/designsafe/apps/api/projects_v2/schema_models/simulation.py b/designsafe/apps/api/projects_v2/schema_models/simulation.py index 3d2a3b6bb7..a937afb3f0 100644 --- a/designsafe/apps/api/projects_v2/schema_models/simulation.py +++ b/designsafe/apps/api/projects_v2/schema_models/simulation.py @@ -37,6 +37,7 @@ class Simulation(MetadataModel): dois: list[str] = [] facility: Optional[DropdownValue] = None + tombstone: bool = False @model_validator(mode="after") def handle_other(self): diff --git a/designsafe/apps/api/projects_v2/views.py b/designsafe/apps/api/projects_v2/views.py index 905f1348e9..be68886c44 100644 --- a/designsafe/apps/api/projects_v2/views.py +++ b/designsafe/apps/api/projects_v2/views.py @@ -31,6 +31,14 @@ logger = logging.getLogger(__name__) +def get_search_filter(query_string): + id_filter = models.Q(value__projectId__icontains=query_string) + title_filter = models.Q(value__title__icontains=query_string) + desc_filter = models.Q(value__description__icontains=query_string) + user_filter = models.Q(value__users__icontains=query_string) + return id_filter | title_filter | desc_filter | user_filter + + class ProjectsView(BaseApiView): """View for listing and creating projects""" @@ -39,17 +47,22 @@ def get(self, request: HttpRequest): """Return the list of projects for a given user.""" offset = int(request.GET.get("offset", 0)) limit = int(request.GET.get("limit", 100)) + query_string = request.GET.get("q", None) # user = get_user_model().objects.get(username="ds_admin") user = request.user if not request.user.is_authenticated: raise ApiException("Unauthenticated user", status=401) - projects = user.projects.order_by("last_updated")[offset : offset + limit] + projects = user.projects.order_by("last_updated") + if query_string: + projects = projects.filter(get_search_filter(query_string)) total = user.projects.count() project_json = { - "result": [project.to_dict() for project in projects], + "result": [ + project.to_dict() for project in projects[offset : offset + limit] + ], "total": total, } @@ -105,7 +118,7 @@ def put(self, request: HttpRequest, project_id: str): ) from exc # Get the new value from the request data - new_value = request.data.get('new_value') + new_value = request.data.get("new_value") # Call the change_project_type function to update the project type updated_project = change_project_type(project_id, new_value) @@ -120,7 +133,7 @@ def put(self, request: HttpRequest, project_id: str): ), } ) - + def patch(self, request: HttpRequest, project_id: str): """Update a project's root metadata""" user = request.user diff --git a/designsafe/apps/api/publications_v2/migrations/0002_publication_is_published.py b/designsafe/apps/api/publications_v2/migrations/0002_publication_is_published.py new file mode 100644 index 0000000000..e22cd8c3fa --- /dev/null +++ b/designsafe/apps/api/publications_v2/migrations/0002_publication_is_published.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.6 on 2024-04-22 21:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("publications_v2_api", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="publication", + name="is_published", + field=models.BooleanField(default=True), + ), + ] diff --git a/designsafe/apps/api/publications_v2/models.py b/designsafe/apps/api/publications_v2/models.py index 0919164c70..8c42413e8f 100644 --- a/designsafe/apps/api/publications_v2/models.py +++ b/designsafe/apps/api/publications_v2/models.py @@ -13,6 +13,7 @@ class Publication(models.Model): project_id = models.CharField(max_length=100, primary_key=True, editable=False) created = models.DateTimeField(default=timezone.now) + is_published = models.BooleanField(default=True) last_updated = models.DateTimeField(auto_now=True) value = models.JSONField( encoder=DjangoJSONEncoder, diff --git a/designsafe/apps/workspace/admin.py b/designsafe/apps/workspace/admin.py index 125fdbbed3..01b3cda683 100644 --- a/designsafe/apps/workspace/admin.py +++ b/designsafe/apps/workspace/admin.py @@ -9,7 +9,7 @@ AppListingEntry, AppVariant, AppTrayCategory, - AppTag + AppTag, ) admin.site.register(AppDescription) @@ -43,6 +43,7 @@ def get_fieldsets(self, request, obj=None): "label", "description", "enabled", + "priority", ) }, ), diff --git a/designsafe/apps/workspace/cms_plugins.py b/designsafe/apps/workspace/cms_plugins.py index 9d344e56fd..232dfd3f23 100644 --- a/designsafe/apps/workspace/cms_plugins.py +++ b/designsafe/apps/workspace/cms_plugins.py @@ -62,7 +62,9 @@ class RelatedApps(CMSPluginBase): def render(self, context, instance: AppListingEntry, placeholder): context = super().render(context, instance, placeholder) - listing_entries = instance.app.related_apps.filter(enabled=True) + listing_entries = instance.app.related_apps.filter(enabled=True).order_by( + "label" + ) serialized_listing = [ { "label": entry.label, diff --git a/designsafe/apps/workspace/migrations/0010_alter_applistingentry_options_and_more.py b/designsafe/apps/workspace/migrations/0010_alter_applistingentry_options_and_more.py new file mode 100644 index 0000000000..fa86a6582c --- /dev/null +++ b/designsafe/apps/workspace/migrations/0010_alter_applistingentry_options_and_more.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.11 on 2024-04-26 19:18 + +from django.db import migrations, models +import django.db.models.functions.comparison + + +class Migration(migrations.Migration): + dependencies = [ + ("workspace", "0009_alter_applistingentry_icon"), + ] + + operations = [ + migrations.AlterModelOptions( + name="applistingentry", + options={ + "ordering": ["is_popular", "label"], + "verbose_name_plural": "App Listing Entries", + }, + ), + migrations.AlterModelOptions( + name="appvariant", + options={ + "ordering": [ + "priority", + django.db.models.functions.comparison.Coalesce("label", "app_id"), + ] + }, + ), + migrations.AddField( + model_name="appvariant", + name="priority", + field=models.IntegerField( + default=0, + help_text="App variant priority, rendered in ascending order.", + ), + ), + ] diff --git a/designsafe/apps/workspace/models/app_entries.py b/designsafe/apps/workspace/models/app_entries.py index a756d3fded..337db3eed5 100644 --- a/designsafe/apps/workspace/models/app_entries.py +++ b/designsafe/apps/workspace/models/app_entries.py @@ -2,6 +2,7 @@ """ from django.db import models +from django.db.models.functions import Coalesce APP_ICONS = [ ("Generic-App", "Generic: Application"), @@ -146,6 +147,8 @@ class Meta: ) ] + ordering = ["is_popular", "label"] + class AppVariant(models.Model): """Model to represent a variant of an app, e.g. a software version or execution environment""" @@ -181,6 +184,11 @@ class AppVariant(models.Model): null=True, ) + priority = models.IntegerField( + help_text="App variant priority, rendered in ascending order.", + default=0, + ) + # HTML Apps html = models.TextField( help_text="HTML definition to display when app is loaded.", @@ -223,3 +231,5 @@ class Meta: name="unique_apps_per_bundle", ) ] + + ordering = ["priority", Coalesce("label", "app_id")] diff --git a/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html b/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html index 8e1c778649..ef3cdd48cf 100644 --- a/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html +++ b/designsafe/apps/workspace/templates/designsafe/apps/workspace/related_apps_plugin.html @@ -1,6 +1,10 @@ +{% if listing|length > 0 %}

Related Applications

{% for app in listing %} {% include "designsafe/apps/workspace/app_card.html" with app=app %} {% endfor %}
+{% else %} + +{% endif %}