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<TProjectListingResponse>( '/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<FileListingResponse>( `/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<ButtonProps> = (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 ( <div className={styles.toolbarRoot}> - <span>(search bar goes here)</span> + <div style={{ marginLeft: '12px' }}>{searchInput ?? null}</div> <div className={styles.toolbarButtonContainer}> <DatafilesModal.Rename api={api} system={system} path={path}> 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<TableProps, 'columns'> -> = ({ 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' ? ( <NavLink className="listing-nav-link" - to={`../${encodeURIComponent(record.path)}`} + to={`${baseRoute ?? '..'}/${encodeURIComponent(record.path)}`} replace={false} > <i @@ -85,7 +93,7 @@ export const FileListing: React.FC< render: (d) => 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<React.PropsWithChildren> = ({ children, @@ -49,49 +49,173 @@ export const DescriptionExpander: React.FC<React.PropsWithChildren> = ({ ); }; +export const LicenseDisplay: React.FC<{ licenseType: string }> = ({ + licenseType, +}) => { + const ENTITY_ICON_MAP: Record<string, string> = { + '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 ( + <div style={{ display: 'flex', alignItems: 'center' }}> + <i className={ENTITY_ICON_MAP[licenseType]} /> + + {licenseType} + </div> + ); +}; + +export const UsernamePopover: React.FC<{ user: TProjectUser }> = ({ user }) => { + const content = ( + <article + style={{ + width: '500px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + }} + > + <Row> + <Col span={8}>Name</Col> + <Col offset={4} span={12}> + <strong> + {user.fname} {user.lname} + </strong> + </Col> + </Row> + <Row gutter={[0, 40]}> + <Col span={8}>Email</Col> + <Col offset={4} span={12}> + <strong>{user.email}</strong> + </Col> + </Row> + <Row gutter={[0, 40]}> + <Col span={8}>Institution</Col> + <Col offset={4} span={12}> + <strong>{user.inst}</strong> + </Col> + </Row> + </article> + ); + return ( + <Popover + trigger="click" + content={content} + title={ + <h3 style={{ marginTop: '0px' }}>{`${user.lname}, ${user.fname}`}</h3> + } + > + <Button type="link" style={{ userSelect: 'text' }}> + <strong> + {user.lname}, {user.fname} + </strong> + </Button> + </Popover> + ); +}; + +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 ( <section style={{ marginBottom: '20px' }}> - <table style={{ width: '100%', marginBottom: '20px' }}> + <table + style={{ width: '100%', marginBottom: '20px', borderSpacing: '200px' }} + > <colgroup> <col style={{ width: '200px' }} /> <col /> </colgroup> <tbody> - <tr className="prj-row"> - <td>PI</td> - <td - style={{ fontWeight: 'bold' }} - >{`${pi?.lname}, ${pi?.fname}`}</td> - </tr> - <tr className="prj-row"> - <td>Co-PIs</td> - <td style={{ fontWeight: 'bold' }}> - {coPis.map((u) => `${u.lname}, ${u.fname}`).join(', ')} - </td> - </tr> - <tr className="prj-row"> - <td>Project Type</td> - <td style={{ fontWeight: 'bold' }}>{projectValue.projectType}</td> - </tr> - <tr className="prj-row"> - <td>Data Types</td> - <td style={{ fontWeight: 'bold' }}> - {projectValue.dataTypes?.map((d) => d.name).join(', ')} - </td> - </tr> - <tr className="prj-row"> - <td>Natural Hazard Type</td> + {pi && projectValue.projectType !== 'other' && ( + <tr className={styles['prj-row']}> + <td>PI</td> + <td style={{ fontWeight: 'bold' }}> + <UsernamePopover user={pi} /> + </td> + </tr> + )} + {coPis.length > 0 && projectValue.projectType !== 'other' && ( + <tr className={styles['prj-row']}> + <td>Co-PIs</td> + <td style={{ fontWeight: 'bold' }}> + {coPis.map((u, i) => ( + <> + <UsernamePopover user={u} /> + {i !== coPis.length - 1 && '; '} + </> + ))} + </td> + </tr> + )} + {projectValue.authors.length > 0 && + projectValue.projectType === 'other' && ( + <tr className={styles['prj-row']}> + <td>Authors</td> + <td style={{ fontWeight: 'bold' }}> + {projectValue.authors.map((u, i) => ( + <> + <UsernamePopover user={u} /> + {i !== projectValue.authors.length - 1 && '; '} + </> + ))} + </td> + </tr> + )} + {projectValue.projectType !== 'other' && ( + <tr className={styles['prj-row']}> + <td>Project Type</td> + <td style={{ fontWeight: 'bold' }}>{projectType}</td> + </tr> + )} + {(projectValue.dataTypes?.length ?? 0) > 0 && ( + <tr className={styles['prj-row']}> + <td>Data Type(s)</td> + <td style={{ fontWeight: 'bold' }}> + {projectValue.dataTypes?.map((d) => d.name).join(', ')} + </td> + </tr> + )} + <tr className={styles['prj-row']}> + <td>Natural Hazard Type(s)</td> <td style={{ fontWeight: 'bold' }}>{`${projectValue.nhTypes .map((t) => t.name) .join(', ')}`}</td> </tr> - <tr className="prj-row" hidden={!projectValue.facilities.length}> + {publicationDate && ( + <tr className={styles['prj-row']}> + <td>Date of Publication</td> + <td style={{ fontWeight: 'bold' }}> + {new Date(publicationDate).toISOString().split('T')[0]} + </td> + </tr> + )} + <tr + className={styles['prj-row']} + hidden={!projectValue.facilities.length} + > <td>Facilities</td> <td style={{ fontWeight: 'bold' }}> {projectValue.facilities.map((t) => ( @@ -99,35 +223,101 @@ export const BaseProjectDetails: React.FC<{ ))} </td> </tr> - <tr> - <td>Events</td> - <td style={{ fontWeight: 'bold' }}> - {projectValue.nhEvents.map((evt) => ( - <div key={JSON.stringify(evt)}> - {evt.eventName} | {evt.location} {evt.eventStart}- - {evt.eventEnd} | Lat {evt.latitude} long {evt.longitude} - </div> - ))} - </td> - </tr> - <tr className="prj-row"> - <td>Awards</td> - <td style={{ fontWeight: 'bold' }}> - {projectValue.awardNumbers.map((t) => ( - <div key={JSON.stringify(t)}> - {[t.name, t.number, t.fundingSource] - .filter((v) => !!v) - .join(' | ')}{' '} - </div> - ))} - </td> - </tr> - <tr className="prj-row"> + {(projectValue.nhEvents?.length ?? 0) > 0 && ( + <tr> + <td>Event(s)</td> + <td style={{ fontWeight: 'bold' }}> + {projectValue.nhEvents.map((evt) => ( + <div key={JSON.stringify(evt)}> + {evt.eventName} | {evt.location} |{' '} + {new Date(evt.eventStart).toISOString().split('T')[0]} + {' ― '} + { + new Date(evt.eventEnd ?? evt.eventStart) + .toISOString() + .split('T')[0] + }{' '} + |{' '} + <a + href={`https://www.google.com/maps/place/${evt.latitude},${evt.longitude}`} + rel="noopener noreferrer" + target="_blank" + > + Lat {evt.latitude} long {evt.longitude} + </a> + </div> + ))} + </td> + </tr> + )} + {(projectValue.awardNumbers?.length ?? 0) > 0 && ( + <tr className={styles['prj-row']}> + <td>Awards</td> + <td style={{ fontWeight: 'bold' }}> + {projectValue.awardNumbers.map((t) => ( + <div key={JSON.stringify(t)}> + {[t.name, t.number, t.fundingSource] + .filter((v) => !!v) + .join(' | ')}{' '} + </div> + ))} + </td> + </tr> + )} + {(projectValue.associatedProjects?.length ?? 0) > 0 && ( + <tr className={styles['prj-row']}> + <td>Related Work</td> + <td style={{ fontWeight: 'bold' }}> + {projectValue.associatedProjects.map((assoc) => ( + <div key={JSON.stringify(assoc)}> + <a + href={assoc.href} + rel="noopener noreferrer" + target="_blank" + > + {assoc.title} + </a> + </div> + ))} + </td> + </tr> + )} + <tr className={styles['prj-row']}> <td>Keywords</td> <td style={{ fontWeight: 'bold' }}> {projectValue.keywords.join(', ')} </td> </tr> + {(projectValue.hazmapperMaps?.length ?? 0) > 0 && ( + <tr className={styles['prj-row']}> + <td>Hazmapper Maps</td> + <td style={{ fontWeight: 'bold' }}> + {(projectValue.hazmapperMaps ?? []).map((m) => ( + <div key={m.uuid}> + {m.name} + <Tooltip title="Open in HazMapper"> + <a + href={`https://hazmapper.tacc.utexas.edu/hazmapper/project-public/${m.uuid}`} + rel="noopener noreferrer" + target="_blank" + > + <i role="none" className="fa fa-external-link"></i> + </a> + </Tooltip> + </div> + ))} + </td> + </tr> + )} + + {projectValue.projectType === 'other' && projectValue.license && ( + <tr className={styles['prj-row']}> + <td>License</td> + <td style={{ fontWeight: 'bold' }}> + <LicenseDisplay licenseType={projectValue.license} /> + </td> + </tr> + )} </tbody> </table> <DescriptionExpander> 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 <i>{data.baseProject.title}</i>. - DesignSafe-CI. (DOI will appear after publication) + .join(', ')}{' '} + ({new Date(entityDetails.publicationDate).getFullYear()}). " + {entityDetails.value.title}", in <i>{data.baseProject.title}</i>. + DesignSafe-CI. ({entityDetails.value.dois && entityDetails.value.dois[0]}) </div> ); }; 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<TBaseProject> = [ }, 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<{ )} </div> ), - children: (sortedChildren ?? []).map((child) => ( - <RecursiveTree - treeData={child} - key={child.id} - defaultOpen={defaultOpenChildren} - /> - )), + children: ( + <> + <PublishedEntityDetails + entityValue={treeData.value} + license={license} + publicationDate={treeData.publicationDate} + /> + {(treeData.value.fileObjs?.length ?? 0) > 0 && ( + <FileListingTable + api="tapis" + system="designsafe.storage.published" + path={treeData.uuid} + scheme="public" + columns={columns} + dataSource={treeData.value.fileObjs} + disabled + /> + )} + {(sortedChildren ?? []).map((child) => ( + <RecursiveTree + treeData={child} + key={child.id} + defaultOpen={defaultOpenChildren} + /> + ))} + </> + ), }, ]} /> @@ -187,15 +210,17 @@ export const ProjectPreview: React.FC<{ projectId: string }> = ({ return ( <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> - {sortedChildren.map((child, idx) => ( - <PublishedEntityDisplay - preview - projectId={projectId} - treeData={child} - defaultOpen={idx === 0} - key={child.id} - /> - ))} + {sortedChildren + .filter((child) => child.name !== 'designsafe.project') + .map((child, idx) => ( + <PublishedEntityDisplay + preview + projectId={projectId} + treeData={child} + defaultOpen={idx === 0} + key={child.id} + /> + ))} </div> ); }; @@ -219,9 +244,13 @@ export const PublicationView: React.FC<{ return ( <div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}> {sortedChildren - .filter((child) => child.version === version) + .filter( + (child) => + child.version === version && child.name !== 'designsafe.project' + ) .map((child, idx) => ( <PublishedEntityDisplay + license={data.baseProject.license} projectId={projectId} treeData={child} defaultOpen={idx === 0} diff --git a/client/modules/datafiles/src/projects/PublishedEntityDetails.tsx b/client/modules/datafiles/src/projects/PublishedEntityDetails.tsx new file mode 100644 index 0000000000..f0e36add8b --- /dev/null +++ b/client/modules/datafiles/src/projects/PublishedEntityDetails.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { TEntityValue } from '@client/hooks'; + +import styles from './BaseProjectDetails.module.css'; +import { + DescriptionExpander, + LicenseDisplay, + UsernamePopover, +} from './BaseProjectDetails'; + +export const PublishedEntityDetails: React.FC<{ + entityValue: TEntityValue; + publicationDate?: string; + license?: string; +}> = ({ entityValue, publicationDate, license }) => { + return ( + <section style={{ marginBottom: '20px' }}> + <table + style={{ width: '100%', marginBottom: '20px', borderSpacing: '200px' }} + > + <colgroup> + <col style={{ width: '200px' }} /> + <col /> + </colgroup> + <tbody> + {entityValue.event && ( + <tr className={styles['prj-row']}> + <td>Event</td> + <td style={{ fontWeight: 'bold' }}>{entityValue.event}</td> + </tr> + )} + + {entityValue.dateStart && ( + <tr className={styles['prj-row']}> + <td>Date(s)</td> + <td style={{ fontWeight: 'bold' }}> + {new Date(entityValue.dateStart).toISOString().split('T')[0]} + {entityValue.dateEnd && ( + <span> + {' ― '} + {new Date(entityValue.dateEnd).toISOString().split('T')[0]} + </span> + )} + </td> + </tr> + )} + + {entityValue.simulationType && ( + <tr className={styles['prj-row']}> + <td>Facility</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.simulationType?.name} + </td> + </tr> + )} + + {(entityValue.authors ?? []).length > 0 && ( + <tr className={styles['prj-row']}> + <td>Authors</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.authors + ?.filter((a) => a.authorship !== false) + .map((u, i) => ( + <> + <UsernamePopover user={u} /> + {i !== + (entityValue.authors?.filter( + (a) => a.authorship !== false + ).length ?? 0) - + 1 && '; '} + </> + ))} + </td> + </tr> + )} + + {entityValue.facility && ( + <tr className={styles['prj-row']}> + <td>Experiment Type</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.facility?.name} + </td> + </tr> + )} + + {entityValue.experimentType && ( + <tr className={styles['prj-row']}> + <td>Facility</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.experimentType?.name} + </td> + </tr> + )} + + {entityValue.equipmentType && ( + <tr className={styles['prj-row']}> + <td>Equipment Type</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.equipmentType?.name} + </td> + </tr> + )} + + {entityValue.procedureStart && ( + <tr className={styles['prj-row']}> + <td>Date of Experiment</td> + <td style={{ fontWeight: 'bold' }}> + { + new Date(entityValue.procedureStart) + .toISOString() + .split('T')[0] + } + {entityValue.procedureEnd && ( + <span> + {' ― '} + { + new Date(entityValue.procedureEnd) + .toISOString() + .split('T')[0] + } + </span> + )} + </td> + </tr> + )} + {entityValue.location && ( + <tr className={styles['prj-row']}> + <td>Site Location</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.location} |{' '} + <a + href={`https://www.google.com/maps/place/${entityValue.latitude},${entityValue.longitude}`} + rel="noopener noreferrer" + target="_blank" + > + Lat {entityValue.latitude} long {entityValue.longitude} + </a> + </td> + </tr> + )} + + {(entityValue.relatedWork?.length ?? 0) > 0 && ( + <tr className={styles['prj-row']}> + <td>Related Work</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.relatedWork.map((assoc) => ( + <div key={JSON.stringify(assoc)}> + <a + href={assoc.href} + rel="noopener noreferrer" + target="_blank" + > + {assoc.title} + </a> + </div> + ))} + </td> + </tr> + )} + {(entityValue.referencedData?.length ?? 0) > 0 && ( + <tr className={styles['prj-row']}> + <td>Related Work</td> + <td style={{ fontWeight: 'bold' }}> + {entityValue.referencedData.map((ref) => ( + <div key={JSON.stringify(ref)}> + {ref.hrefType && `${ref.hrefType} | `} + <a href={ref.doi} rel="noopener noreferrer" target="_blank"> + {ref.title} + </a> + </div> + ))} + </td> + </tr> + )} + + {publicationDate && ( + <tr className={styles['prj-row']}> + <td>Date Published</td> + <td style={{ fontWeight: 'bold' }}> + {new Date(publicationDate).toISOString().split('T')[0]} + </td> + </tr> + )} + + {entityValue.dois && entityValue.dois[0] && ( + <tr className={styles['prj-row']}> + <td>DOI</td> + <td style={{ fontWeight: 'bold' }}>{entityValue.dois[0]}</td> + </tr> + )} + + {license && ( + <tr className={styles['prj-row']}> + <td>License</td> + <td style={{ fontWeight: 'bold' }}> + <LicenseDisplay licenseType={license} /> + </td> + </tr> + )} + </tbody> + </table> + <DescriptionExpander> + <strong>Description: </strong> + {entityValue.description} + </DescriptionExpander> + </section> + ); +}; 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 ( + <section style={{ backgroundColor: '#fafafa' }}> + <article className={styles.projectTypeOptions}> + <label htmlFor="facility-select">Facility</label> + + <Select + id="facility-select" + allowClear + virtual={false} + value={searchParams.get('facility')} + onChange={(v) => { + setSearchParam('facility', v); + }} + options={dropdownOptions.facilityOptions} + popupMatchSelectWidth={false} + style={{ width: '100%' }} + placeholder="All Facilities" + /> + </article> + + <article className={styles.projectTypeOptions}> + <div className={styles.checkboxRow}> + <Checkbox + checked={searchParams + .getAll('project-type') + .includes('experimental')} + onChange={() => toggleProjectTypeFilter('experimental')} + /> + + <strong>Experimental</strong> + </div> + <div> + <label + className={styles.sidebarLabel} + htmlFor="experiment-type-select" + > + Experiment Type + </label> + <Select + id="experiment-type-select" + virtual={false} + allowClear + disabled={ + !Object.keys(dropdownOptions.experimentTypeOptions).includes( + searchParams.get('facility') ?? '' + ) + } + value={searchParams.get('experiment-type')} + onChange={(v) => setSearchParam('experiment-type', v)} + options={ + searchParams.get('facility') + ? dropdownOptions.experimentTypeOptions[ + searchParams.get('facility') ?? '' + ] + : [] + } + style={{ width: '100%' }} + placeholder="All Types" + /> + </div> + </article> + <article className={styles.projectTypeOptions}> + <div className={styles.checkboxRow}> + <Checkbox + checked={searchParams.getAll('project-type').includes('simulation')} + onChange={() => toggleProjectTypeFilter('simulation')} + /> + + <strong>Simulation</strong> + </div> + <label className={styles.sidebarLabel} htmlFor="simulation-type-select"> + Simulation Type + </label> + + <Select + id="simulation-type-select" + options={dropdownOptions.simulationTypeOptions} + value={searchParams.get('sim-type')} + onChange={(v) => setSearchParam('sim-type', v)} + allowClear + style={{ width: '100%' }} + placeholder="All Types" + /> + </article> + <article className={styles.projectTypeOptions}> + <div className={styles.checkboxRow}> + <Checkbox + checked={searchParams + .getAll('project-type') + .includes('field-research')} + onChange={() => toggleProjectTypeFilter('field-research')} + /> + + <strong>Field Research</strong> + </div> + <div> + <label className={styles.sidebarLabel} htmlFor="fr-type-select"> + Field Research Type + </label> + <Select + id="fr-type-select" + allowClear + options={dropdownOptions.frTypeOptions} + value={searchParams.get('fr-type')} + onChange={(v) => setSearchParam('fr-type', v)} + style={{ width: '100%', marginBottom: '5px' }} + placeholder="All Types" + /> + </div> + <div> + <label className={styles.sidebarLabel} htmlFor="nh-year-select"> + Natural Hazard Year + </label> + <Select + id="nh-year-select" + options={yearOptions} + value={searchParams.get('nh-year')} + onChange={(v) => setSearchParam('nh-year', v)} + allowClear + style={{ width: '100%' }} + placeholder="All Years" + /> + </div> + </article> + <article className={styles.projectTypeOptions}> + <div className={styles.checkboxRow}> + <Checkbox + checked={searchParams.getAll('project-type').includes('hybrid-sim')} + onChange={() => toggleProjectTypeFilter('hybrid-sim')} + /> + + <strong>Hybrid Simulation</strong> + </div> + <div> + <label className={styles.sidebarLabel} htmlFor="hybsim-type-select"> + Hybrid Simulation Type + </label> + <Select + id="hybsim-type-select" + options={dropdownOptions.HybridSimTypeOptions} + value={searchParams.get('hyb-sim-type')} + onChange={(v) => setSearchParam('hyb-sim-type', v)} + allowClear + style={{ width: '100%' }} + placeholder="All Types" + /> + </div> + </article> + <article className={styles.projectTypeOptions}> + {' '} + <div className={styles.checkboxRow}> + <Checkbox + checked={searchParams.getAll('project-type').includes('other')} + onChange={() => toggleProjectTypeFilter('other')} + /> + + <strong>Other</strong> + </div> + <div> + <label className={styles.sidebarLabel} htmlFor="data-type-select"> + Data Type + </label> + <Select + options={dropdownOptions.dataTypeOptions} + value={searchParams.get('data-type')} + onChange={(v) => setSearchParam('data-type', v)} + allowClear + style={{ width: '100%' }} + placeholder="All Types" + /> + </div> + </article> + </section> + ); +}; diff --git a/client/modules/datafiles/src/publications/PublicationSearchToolbar/PublicationSearchToolbar.tsx b/client/modules/datafiles/src/publications/PublicationSearchToolbar/PublicationSearchToolbar.tsx new file mode 100644 index 0000000000..50e7ccb189 --- /dev/null +++ b/client/modules/datafiles/src/publications/PublicationSearchToolbar/PublicationSearchToolbar.tsx @@ -0,0 +1,94 @@ +import { Button, Form, Input, Select } from 'antd'; +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import * as dropdownOptions from '../../projects/forms/ProjectFormDropdowns'; +export const PublicationSearchToolbar: React.FC = () => { + const [searchParams, setSearchParams] = useSearchParams(); + 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 ( + <div + style={{ + display: 'flex', + alignItems: 'center', + gap: '10px', + justifyContent: 'space-between', + flexWrap: 'wrap', + }} + > + <Form + onFinish={(data) => setSearchParam('q', data.query)} + style={{ display: 'inline-flex', flex: 1, minWidth: '250px' }} + > + <Button htmlType="submit" type="primary" className="success-button"> + <i className="fa fa-search" /> + Search + </Button> + <Form.Item name="query" style={{ marginBottom: 0, flex: 1 }}> + <Input + placeholder="Author, Title, Keyword, Description, Natural Hazard Event, or Project ID" + style={{ flex: 1 }} + /> + </Form.Item> + </Form> + <div> + <label htmlFor="nh-type-select" style={{ margin: 0 }}> + Natural Hazard Type + </label> + + <Select + style={{ width: '150px' }} + virtual={false} + allowClear + id="nh-type-select" + placeholder="All Types" + options={dropdownOptions.nhTypeOptions} + popupMatchSelectWidth={false} + value={searchParams.get('nh-type')} + onChange={(v) => setSearchParam('nh-type', v)} + /> + </div> + + <div> + <label htmlFor="publication-year-select" style={{ margin: 0 }}> + Year Published + </label> + + <Select + style={{ width: '150px' }} + id="publication-year-select" + options={yearOptions} + allowClear + placeholder="All Years" + popupMatchSelectWidth={false} + virtual={false} + value={searchParams.get('pub-year')} + onChange={(v) => setSearchParam('pub-year', v)} + /> + </div> + + <Button type="link" onClick={() => setSearchParams(undefined)}> + Clear Filters + </Button> + </div> + ); +}; 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<TPublicationListingItem> = [ { render: (_, record) => record.projectId, title: 'Project ID', - width: '10%', + width: '100px', }, { render: (_, record) => <Link to={record.projectId}>{record.title}</Link>, @@ -19,8 +19,13 @@ const columns: TableColumnsType<TPublicationListingItem> = [ 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 ( + <Form + onFinish={(data) => onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + <Form.Item name="query" style={{ marginBottom: 0 }}> + <Input placeholder="Search Data Files" style={{ width: '250px' }} /> + </Form.Item> + <Button htmlType="submit"> + <i className="fa fa-search"></i> + </Button> + </Form> + ); +}; + 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 ( <Layout style={{ gap: '5px', minWidth: '500px' }}> - <DatafilesToolbar /> + <DatafilesToolbar searchInput={<FileListingSearchBar />} /> {true && ( <BaseFileListingBreadcrumb api={api} diff --git a/client/src/datafiles/layouts/projects/ProjectCurationLayout.tsx b/client/src/datafiles/layouts/projects/ProjectCurationLayout.tsx index 534c016d6f..8b0b06f4a8 100644 --- a/client/src/datafiles/layouts/projects/ProjectCurationLayout.tsx +++ b/client/src/datafiles/layouts/projects/ProjectCurationLayout.tsx @@ -1,4 +1,5 @@ import { + DatafilesBreadcrumb, ManageCategoryModal, ManagePublishableEntityModal, ProjectCurationFileListing, @@ -9,7 +10,7 @@ import { useProjectDetail } from '@client/hooks'; import { Button } from 'antd'; import React from 'react'; -import { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; const PublishableEntityButton: React.FC<{ projectId: string }> = ({ 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 <div>loading...</div>; return ( <div style={{ paddingBottom: '50px' }}> <div style={{ display: 'flex', alignItems: 'center', gap: '3rem' }}> @@ -163,6 +166,20 @@ export const ProjectCurationLayout: React.FC = () => { </RelateDataModal> </span> </div> + <DatafilesBreadcrumb + initialBreadcrumbs={[]} + path={path ?? ''} + baseRoute={`/projects/${projectId}/curation`} + systemRootAlias={data.baseProject.value.projectId} + systemRoot="" + itemRender={(obj) => { + return ( + <Link className="breadcrumb-link" to={obj.path ?? '/'}> + {obj.title} + </Link> + ); + }} + /> <ProjectCurationFileListing projectId={projectId} path={path ?? ''} /> </div> ); 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 ( + <Form + onFinish={(data) => onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + <Form.Item name="query" style={{ marginBottom: 0 }}> + <Input placeholder="Search Data Files" style={{ width: '250px' }} /> + </Form.Item> + <Button htmlType="submit"> + <i className="fa fa-search"></i> + </Button> + </Form> + ); +}; + export const ProjectDetailLayout: React.FC = () => { const { projectId } = useParams(); const { data } = useProjectDetail(projectId ?? ''); @@ -10,6 +42,7 @@ export const ProjectDetailLayout: React.FC = () => { return ( <section> + <DatafilesToolbar searchInput={<FileListingSearchBar />} /> <ProjectTitleHeader projectId={projectId} /> <BaseProjectDetails projectValue={data.baseProject.value} /> <Outlet /> 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 ( + <Form + onFinish={(data) => onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + <Form.Item name="query" style={{ marginBottom: 0 }}> + <Input placeholder="Find in My Projects" style={{ width: '300px' }} /> + </Form.Item> + <Button htmlType="submit"> + <i className="fa fa-search"></i> + </Button> + </Form> + ); +}; + export const ProjectListingLayout: React.FC = () => { return ( <Layout> - <div>Placeholder for the project listing searchbar</div> + <DatafilesToolbar searchInput={<ProjectListingSearchBar />} /> <div style={{ flex: '1 0 0 ', height: '100%', overflow: 'auto' }}> <ProjectListing /> </div> 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 ( + <Form + onFinish={(data) => onSubmit(data.query)} + style={{ display: 'inline-flex' }} + > + <Form.Item name="query" style={{ marginBottom: 0 }}> + <Input placeholder="Search Data Files" style={{ width: '250px' }} /> + </Form.Item> + <Button htmlType="submit"> + <i className="fa fa-search"></i> + </Button> + </Form> + ); +}; 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 ( + <Navigate + to={`/public/designsafe.storage.published/${projectId}/${projectId}?q=${searchParams.get( + 'q' + )}`} + /> + ); + } + + const publicationDate = data.tree.children.find( + (c) => c.value.projectId === projectId + )?.publicationDate; + return ( <div style={{ width: '100%', paddingBottom: '100px' }}> - <div className="prj-head-title" style={{ marginBottom: '20px' }}> + <DatafilesToolbar searchInput={<FileListingSearchBar />} /> + <div + className="prj-head-title" + style={{ marginTop: '20px', marginBottom: '20px' }} + > <strong>{data.baseProject.projectId}</strong> | {data.baseProject.title} </div> - <BaseProjectDetails projectValue={data?.baseProject} /> + <BaseProjectDetails + projectValue={data?.baseProject} + publicationDate={publicationDate} + /> <Outlet /> </div> ); 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 ( <div style={{ width: '100%' }}> <PublicationView projectId={projectId} /> + {['other', 'field_reconnaissance'].includes( + data.baseProject.projectType + ) && ( + <FileListing + scroll={{ y: 500 }} + api="tapis" + system="designsafe.storage.published" + path={data.baseProject.projectId} + baseRoute="." + /> + )} </div> ); }; 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 ( - <Layout> - <div>Placeholder for the publication listing searchbar</div> - <div style={{ flex: '1 0 0 ', height: '100%', overflow: 'auto' }}> - <PublishedListing /> + <Layout style={{ paddingBottom: '20px' }}> + <div + style={{ + backgroundColor: 'transparent', + padding: 0, + marginBottom: '20px', + }} + > + <PublicationSearchToolbar /> </div> + <Layout style={{ gap: '10px' }}> + <Layout.Content + style={{ + display: 'flex', + flexDirection: 'column', + flex: '1 0 0', + overflow: 'auto', + }} + > + <div style={{ flex: '1 0 0', height: '100%', overflow: 'auto' }}> + <PublishedListing /> + </div> + </Layout.Content> + <Layout.Sider width={200} style={{ backgroundColor: 'transparent' }}> + <PublicationSearchSidebar /> + </Layout.Sider> + </Layout> </Layout> ); }; 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 ca6f2422bd..0008aadb8a 100644 --- a/designsafe/apps/api/projects_v2/views.py +++ b/designsafe/apps/api/projects_v2/views.py @@ -29,6 +29,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""" @@ -36,17 +44,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, } @@ -101,7 +114,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) @@ -116,7 +129,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,