diff --git a/.github/workflows/.trivyignore b/.github/workflows/.trivyignore index 17b21983e..bd04c58e4 100644 --- a/.github/workflows/.trivyignore +++ b/.github/workflows/.trivyignore @@ -21,4 +21,7 @@ CVE-2026-33671 # Vulnerability in image dependency libpng that is included in the image and we cannot fix Apr-1-2026 CVE-2026-33416 -CVE-2026-33636 \ No newline at end of file +CVE-2026-33636 + +# lodash-es is pinned as a dependency on a vulnerable version in formik and cannot be upgraded by us Apr-3-2026 +CVE-2026-4800 \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7e89e59ba..0af413366 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,14 +19,14 @@ "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^5.90.20", "@tanstack/react-table": "^8.20.5", - "axios": "^1.13.5", + "axios": "^1.15.0", "bootstrap": "^5.3.3", "bootstrap-css": "^4.0.0-alpha.5", "classnames": "^2.5.1", "cytoscape": "3.30.4", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", - "formik": "^2.4.6", + "formik": "^2.4.9", "i18next": "^24.0.5", "intro.js": "^7.2.0", "intro.js-react": "^1.0.0", @@ -34,7 +34,7 @@ "jsonpath": "^1.1.1", "keycloak-js": "^26.0.7", "leaflet": "^1.9.4", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "moment": "^2.30.1", "msw": "^2.6.7", "openseadragon": "^5.0.0", @@ -5716,14 +5716,14 @@ } }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axobject-query": { @@ -7521,9 +7521,9 @@ } }, "node_modules/formik": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.6.tgz", - "integrity": "sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==", + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/formik/-/formik-2.4.9.tgz", + "integrity": "sha512-5nI94BMnlFDdQRBY4Sz39WkhxajZJ57Fzs8wVbtsQlm5ScKIR1QLYqv/ultBnobObtlUyxpxoLodpixrsf36Og==", "funding": [ { "type": "individual", @@ -9043,9 +9043,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash-es": { @@ -10604,10 +10604,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/punycode": { "version": "2.3.1", diff --git a/package.json b/package.json index e955f398a..7b2978c44 100644 --- a/package.json +++ b/package.json @@ -29,14 +29,14 @@ "@tanstack/match-sorter-utils": "^8.19.4", "@tanstack/react-query": "^5.90.20", "@tanstack/react-table": "^8.20.5", - "axios": "^1.13.5", + "axios": "^1.15.0", "bootstrap": "^5.3.3", "bootstrap-css": "^4.0.0-alpha.5", "classnames": "^2.5.1", "cytoscape": "3.30.4", "date-fns": "^4.1.0", "date-fns-tz": "^3.2.0", - "formik": "^2.4.6", + "formik": "^2.4.9", "i18next": "^24.0.5", "intro.js": "^7.2.0", "intro.js-react": "^1.0.0", @@ -44,7 +44,7 @@ "jsonpath": "^1.1.1", "keycloak-js": "^26.0.7", "leaflet": "^1.9.4", - "lodash": "^4.17.21", + "lodash": "^4.18.1", "moment": "^2.30.1", "msw": "^2.6.7", "openseadragon": "^5.0.0", diff --git a/src/components/Cards/DigitalSpecimenCard/DigitalSpecimenCard.scss b/src/components/Cards/DigitalSpecimenCard/DigitalSpecimenCard.scss new file mode 100644 index 000000000..baafd7049 --- /dev/null +++ b/src/components/Cards/DigitalSpecimenCard/DigitalSpecimenCard.scss @@ -0,0 +1,33 @@ +.digital-specimen-card { + .ds-card-header { + margin-bottom: 16px; + } + .ds-card-georeference { + width: 100%; + height: 320px; + margin-block-end: 8px; + } + .ds-card-citation { + max-height: 92px; + width: 100%; + background-color: #F2F3F8; + padding: 16px; + margin-block-end: 8px; + + p { + font-size: 14px; + + a { + color: black; + text-decoration: underline; + } + + } + } + .ds-card-body { + display: grid; + grid-template-columns: 200px 1fr; + gap: 8px 16px; + align-items: start; + } +} \ No newline at end of file diff --git a/src/components/Cards/DigitalSpecimenCard/DigitalSpecimenCard.tsx b/src/components/Cards/DigitalSpecimenCard/DigitalSpecimenCard.tsx new file mode 100644 index 000000000..31d65f427 --- /dev/null +++ b/src/components/Cards/DigitalSpecimenCard/DigitalSpecimenCard.tsx @@ -0,0 +1,81 @@ +/* Import components */ +import { CopyIcon, Pencil2Icon } from "@radix-ui/react-icons"; +import { Button, Card } from "@radix-ui/themes"; +import { LabelValuePair } from "components/LabelValuePair/LabelValuePair"; +import { OpenStreetMap } from "components/elements/customUI/CustomUI"; + +/* Import styles */ +import './DigitalSpecimenCard.scss'; + +type Props = { + cardHeader: string, + annotate?: boolean, + copy?: boolean, + fragment: any, + georeference?: boolean + citation?: boolean +} + +export const DigitalSpecimenCard = ({ cardHeader, annotate, copy, fragment, georeference = false, citation = false }: Props) => { + const craftCitation = (fragment: any) => { + return ( + <> + + {fragment['organisationName'].value ?? fragment['organisationId'].value} + + {` (${new Date().getFullYear()}). `} + + Distributed System of Scientific Collections + + {`. [Dataset]. `} + + {fragment['digitalSpecimenId'].value} + + + ) + + } + return ( + +
+

{cardHeader}

+ { annotate && + + } + { copy && + + } +
+ { georeference && +
+ +
+ } + { citation && +
+

{craftCitation(fragment)}

+
+ } +
+ {Object.entries(fragment).map(([key, item]: [string, any]) => ( + + ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/Hero/Hero.scss b/src/components/Hero/Hero.scss index cd52d536a..5799fece2 100644 --- a/src/components/Hero/Hero.scss +++ b/src/components/Hero/Hero.scss @@ -5,6 +5,12 @@ header { display: flex; justify-content: space-between; margin-block-end: var(--spacing-l); + + div { + button { + margin-inline-end: var(--spacing-sm); + } + } } #hero-title { display: flex; @@ -15,6 +21,11 @@ header { margin-block-start: var(--spacing-sm); } } + #hero-badges { + span { + margin-inline-end: var(--spacing-sm); + } + } #hero-content { display: flex; flex-direction: column; diff --git a/src/components/Hero/Hero.tsx b/src/components/Hero/Hero.tsx index ffb863510..f08930c71 100644 --- a/src/components/Hero/Hero.tsx +++ b/src/components/Hero/Hero.tsx @@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, useState } from "react"; import { format } from "date-fns"; /* Import components */ -import { ArrowLeftIcon, ClipboardCopyIcon, CopyIcon, PlusIcon } from "@radix-ui/react-icons"; +import { ArrowLeftIcon, ClipboardCopyIcon, CopyIcon, Pencil2Icon, PlusIcon } from "@radix-ui/react-icons"; import { Badge, Button, Dialog, Flex } from "@radix-ui/themes"; import { useLocation, useNavigate } from "react-router-dom"; @@ -19,12 +19,14 @@ import { useClipboard } from "hooks/useClipboard"; type Props = { title: string; - description: string; - badge?: string[]; + description?: string; + badge?: { content: string, type: "soft" | "solid" | "outline" | "surface", color: "sky" | "grass" }[]; navigateTo?: { pathName: string; text: string }; showShareButton?: boolean; details?: any; showCreateButton?: boolean; + isHtml?: boolean; + annotate?: boolean; } /** @@ -38,7 +40,7 @@ type Props = { * @param create Boolean that indicates if the functionality for creating a VC should be working * @returns A JSX element that shows a Hero banner with information and possibly navigation */ -export const Hero = ( { title, description, badge, navigateTo, showShareButton, details, showCreateButton }: Props) => { +export const Hero = ( { title, description, badge, navigateTo, showShareButton, details, showCreateButton, isHtml = false, annotate }: Props) => { /* Hooks */ const navigate = useNavigate(); const isAllowedToCreateVC = useHasRole('dissco-virtual-collection'); @@ -82,6 +84,12 @@ export const Hero = ( { title, description, badge, navigateTo, showShareButton, }
+ {annotate && + + } {showShareButton &&
- - {badge?.map((badge: string) => { - return ( - {badge} - ) - })} + +
+ {badge?.map(({ content, type, color }) => { + if (!content) return null; + return ( + {content} + ) + })} +
-

{title}

+ {isHtml ? ( +

+ ) : ( +

{ title }

+ )} {showCreateButton &&
+ {description &&

{description} @@ -136,6 +152,7 @@ export const Hero = ( { title, description, badge, navigateTo, showShareButton, )}

+ } {details && <> diff --git a/src/components/LabelValuePair/LabelValuePair.scss b/src/components/LabelValuePair/LabelValuePair.scss new file mode 100644 index 000000000..37cfeb161 --- /dev/null +++ b/src/components/LabelValuePair/LabelValuePair.scss @@ -0,0 +1,39 @@ +.property-row-fragment { + display: contents; + + .property-label { + font-size: 14px; + font-weight: 500; + } + + .verbatim { + font-size: 12px; + color: grey; + } + .property-value { + font-size: 14px; + font-weight: 400; + + a { + svg { + margin-inline-start: 4px; + } + } + + .verbatim { + font-size: 12px; + color: grey; + + span { + margin-inline-start: 8px; + } + } + .btn-as-link { + padding: 0; + + svg { + margin-inline-start: var(--spacing-xs); + } + } + } +} \ No newline at end of file diff --git a/src/components/LabelValuePair/LabelValuePair.tsx b/src/components/LabelValuePair/LabelValuePair.tsx new file mode 100644 index 000000000..f85ddead1 --- /dev/null +++ b/src/components/LabelValuePair/LabelValuePair.tsx @@ -0,0 +1,82 @@ +/* Import styles */ +import { Badge } from '@radix-ui/themes'; +import './LabelValuePair.scss'; + +/* Import components */ +import { CopyIcon, ExternalLinkIcon } from '@radix-ui/react-icons'; + +/* Import hooks */ +import { useClipboard } from 'hooks/useClipboard'; + +/* Import utils */ +import { RetrieveEnvVariable } from 'app/Utilities'; + +type Props = { + item: { + label: string; + value: string; + isHtml: boolean; + type: string; + hidden: boolean; + } +} + +/** + * Renders a single row of data. + * If the value is missing/null, it returns null to hide the row. + */ +export const LabelValuePair = ({ item }: Props) => { + /* Base variables */ + const { copy } = useClipboard(); + + if (!item.value || item.hidden) return null; + + /* Return correct piece of HTML based on the type */ + const setCorrectItemType = () => { + switch (item.type) { + case 'url': + return ( + + Catalogue of Life + + + ); + case 'verbatim': + return ( + + {item.value} + Original + + ) + case 'copy': + return ( + + ) + default: + return ( + item?.isHtml ? ( + + ) : ( + {item.value} + ) + ) + } + + } + + return ( +
+ {item.type === 'verbatim' ? + {item.label} + : {item.label} + } + + + {setCorrectItemType()} + +
+ ); +}; \ No newline at end of file diff --git a/src/components/digitalSpecimen/components/contentBlock/components/DigitalSpecimenOverview.tsx b/src/components/digitalSpecimen/components/contentBlock/components/DigitalSpecimenOverview.tsx index 610d1515c..9c3e2a171 100644 --- a/src/components/digitalSpecimen/components/contentBlock/components/DigitalSpecimenOverview.tsx +++ b/src/components/digitalSpecimen/components/contentBlock/components/DigitalSpecimenOverview.tsx @@ -271,7 +271,10 @@ const DigitalSpecimenOverview = (props: Props) => { {/* Geological reference map */} - + diff --git a/src/components/elements/customUI/openStreetMap/OpenStreetMap.tsx b/src/components/elements/customUI/openStreetMap/OpenStreetMap.tsx index a18b72020..9b6d1bd3a 100644 --- a/src/components/elements/customUI/openStreetMap/OpenStreetMap.tsx +++ b/src/components/elements/customUI/openStreetMap/OpenStreetMap.tsx @@ -1,31 +1,29 @@ /* Import Dependencies */ import { MapContainer, TileLayer } from 'react-leaflet'; -/* Import Types */ -import { Dict } from 'app/Types'; - /* Import Components */ import OpenStreetMapMarker from './OpenStreetMapMarker'; /* Props Type */ type Props = { - georeference: Dict | undefined + longitude?: number, + latitude?: number, }; /** * Component that renders a Leaflet map based on the given geological reference, - * @param georeference A geological reference object holding the latitude and longitude among other properties + * @param longitude Longitude decimal number + * @param latitude Latitude decimal number * @returns JSX Component */ -const OpenStreetMap = (props: Props) => { - const { georeference } = props; - +const OpenStreetMap = ({longitude, latitude}: Props) => { + console.log(longitude, latitude) return (
- {(georeference && 'dwc:decimalLatitude' in georeference && 'dwc:decimalLongitude' in georeference) ? - { url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> - + :

Geographical map could not be created due to a lack of coordinates

} diff --git a/src/components/elements/customUI/openStreetMap/OpenStreetMapMarker.tsx b/src/components/elements/customUI/openStreetMap/OpenStreetMapMarker.tsx index 27b791f3b..802217b03 100644 --- a/src/components/elements/customUI/openStreetMap/OpenStreetMapMarker.tsx +++ b/src/components/elements/customUI/openStreetMap/OpenStreetMapMarker.tsx @@ -5,38 +5,34 @@ import { Marker, Popup, useMap } from 'react-leaflet'; /* Import Hooks */ import { useTrigger } from 'app/Hooks'; -/* Import Types */ -import { Dict } from 'app/Types'; - /* Import Sources */ import markerIconPng from 'leaflet/dist/images/marker-icon.png'; /* Props Type */ type Props = { - georeference: Dict + longitude: number, + latitude: number, }; -const OpenStreetMapMarker = (props: Props) => { - const { georeference } = props; - +const OpenStreetMapMarker = ({longitude, latitude}: Props) => { /* Hooks */ const leafletMap = useMap(); const trigger = useTrigger(); trigger.SetTrigger(() => { - leafletMap.setView([georeference['dwc:decimalLatitude'], georeference['dwc:decimalLongitude']]); - }, [georeference]); + leafletMap.setView([latitude, longitude]); + }, [longitude, latitude]); return ( -

Coordinates

-

Latitude: {georeference['dwc:decimalLatitude']}

-

Longitude: {georeference['dwc:decimalLongitude']}

+

Latitude: {latitude}

+

Longitude: {longitude}

); diff --git a/src/components/search/components/idCard/IdCard.tsx b/src/components/search/components/idCard/IdCard.tsx index 05ab553d8..37b10e974 100644 --- a/src/components/search/components/idCard/IdCard.tsx +++ b/src/components/search/components/idCard/IdCard.tsx @@ -178,7 +178,10 @@ const IdCard = () => { ) && - + } diff --git a/src/hooks/useDigitalSpecimen.ts b/src/hooks/useDigitalSpecimen.ts new file mode 100644 index 000000000..ad7d3e7fc --- /dev/null +++ b/src/hooks/useDigitalSpecimen.ts @@ -0,0 +1,24 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDigitalSpecimenComplete } from 'services/digitalSpecimenService/getDigitalSpecimenComplete'; +import { mapDigitalSpecimen } from 'utils/DataMappers/digitalSpecimenDataMapper'; + +/* Base constants */ +const staleTime = 1000 * 60 * 5; // How long until the time is stale +const gcTime = 1000 * 60 * 10; // Cache time: How long to store it in the cache + +/* useQuery hook to retrieve the complete Digital Specimen data object by calling the getDigitalSpecimenComplete service */ +export const useDigitalSpecimenComplete = ({ doi, version }: + { doi: string, version?: number }) => { + return useQuery({ + queryKey: ['digitalSpecimen', doi, version], + queryFn: () => getDigitalSpecimenComplete({ doi, version }), + select: (data) => { + return { + ...data, + ...mapDigitalSpecimen(data), + } + }, + staleTime, + gcTime + }); +}; \ No newline at end of file diff --git a/src/pages/DigitalSpecimenOverview/DigitalSpecimenOverview.scss b/src/pages/DigitalSpecimenOverview/DigitalSpecimenOverview.scss new file mode 100644 index 000000000..ab2c6ea06 --- /dev/null +++ b/src/pages/DigitalSpecimenOverview/DigitalSpecimenOverview.scss @@ -0,0 +1,23 @@ +.digital-specimen-container { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + + #ds-right-column, #ds-left-column { + .digital-specimen-card { + margin-block-end: 24px; + padding: 16px; + + .ds-card-header { + display: flex; + justify-content: space-between; + align-items: center; + + h2 { + font-size: 16px; + margin: 0; + } + } + } + } +} \ No newline at end of file diff --git a/src/pages/DigitalSpecimenOverview/DigitalSpecimenOverview.tsx b/src/pages/DigitalSpecimenOverview/DigitalSpecimenOverview.tsx new file mode 100644 index 000000000..f11ec9966 --- /dev/null +++ b/src/pages/DigitalSpecimenOverview/DigitalSpecimenOverview.tsx @@ -0,0 +1,48 @@ +/* Import components */ +import { DigitalSpecimenCard } from 'components/Cards/DigitalSpecimenCard/DigitalSpecimenCard'; +import { Hero } from 'components/Hero/Hero'; + +/* Import hooks */ +import { useDigitalSpecimenComplete } from 'hooks/useDigitalSpecimen'; + +/* Import styling */ +import './DigitalSpecimenOverview.scss'; + +const DigitalSpecimenDetails = () => { + /* Base variables */ + const url = new URL(globalThis.location.href); + const segments = url.pathname.split('/'); + const identifier = segments.slice(2).join("/"); + const { data: specimen, isLoading, isError } = useDigitalSpecimenComplete({ doi: identifier}); + + if (isLoading) return

Retrieving the Digital Specimen Details...

; + if (!specimen) return

No data found

+ if (isError) return

Something went wrong with fetching the Digital Specimen. Please try again later.

; + + return ( + <> + + +
+
+ + +
+
+ + + +
+
+ + ); +}; + +export default DigitalSpecimenDetails; \ No newline at end of file diff --git a/src/pages/VirtualCollectionDetails/VirtualCollectionDetails.tsx b/src/pages/VirtualCollectionDetails/VirtualCollectionDetails.tsx index 01e7c39ce..903c92c52 100644 --- a/src/pages/VirtualCollectionDetails/VirtualCollectionDetails.tsx +++ b/src/pages/VirtualCollectionDetails/VirtualCollectionDetails.tsx @@ -66,7 +66,7 @@ const VirtualCollectionDetails = () => { { + let endPoint: string; + + if (version) { + endPoint = `digital-specimen/v1/${doi}/${version}/full`; + } else { + endPoint = `digital-specimen/v1/${doi}/full`; + } + try { + /* Call service and wait for response */ + const response = await apiClient.get(endPoint, { + params: { + doi, + version + } + }); + + /* Throw error if response is not as expected */ + if(!response.data?.data) { + throw new Error('Incorrect response format'); + } + + /* Return response data */ + return response.data; + } catch (error) { + /* If error, throw error with generic error message */ + console.error('Error fetching the digital specimen:', error); + + /* Rethrow error for useQuery */ + throw error; + } +} \ No newline at end of file diff --git a/src/utils/DataMappers/digitalSpecimenDataMapper.ts b/src/utils/DataMappers/digitalSpecimenDataMapper.ts new file mode 100644 index 000000000..4c0d55287 --- /dev/null +++ b/src/utils/DataMappers/digitalSpecimenDataMapper.ts @@ -0,0 +1,63 @@ +/* Import schemas */ +import DIGITAL_SPECIMEN_SCHEMA_MAP from "./schemas/DigitalSpecimenSchema"; + +/* Import types */ +import { DigitalSpecimenUIModel, SchemaMap, UIProperty } from "./types/dataMapperTypes"; + +/** + * Function to get the accepted identification or the first one it can find + * @param ds The digital specimen object + * @returns Either the accepted identification, the first identification it can find or null in the edge case that there is none + */ +const getAcceptedIdentification = (ds: any) => { + const identifications = ds["ods:hasIdentifications"]; + + /* Find verified identification or fallback to first identification */ + const primary = identifications?.find((item: any) => item["ods:isVerifiedIdentification"]) + ?? identifications?.[0]; + + /* Return the taxon identification or null if for some reason there is no identification*/ + return primary?.["ods:hasTaxonIdentifications"]?.[0] ?? null; +} + +/** + * Function to retrieve the primary event once + * @param ds The digital specimen object + * @returns Either the primary event if there is one or null + */ +const getPrimaryEvent = (ds: any) => { + return ds["ods:hasEvents"]?.[0] ?? null; +} + +/** + * Transforms raw Digital Specimen data into a UI-ready model + * based on the DIGITAL_SPECIMEN_SCHEMA_MAP definitions. + * It is executed in the useDigitalSpecimen hook immediately when the call is being done. + */ +export const mapDigitalSpecimen = (rawData: any): DigitalSpecimenUIModel | null => { + const ds = rawData?.data?.attributes?.digitalSpecimen; + if (!ds) return null; + + /* Find acceptedIdentification or second best option */ + const acceptedIdentification = getAcceptedIdentification(ds); + const primaryEvent = getPrimaryEvent(ds); + + return Object.fromEntries( + Object.entries(DIGITAL_SPECIMEN_SCHEMA_MAP as SchemaMap).map(([groupKey, fields]) => { + const mappedFields: Record = Object.fromEntries( + Object.entries(fields).map(([fieldKey, config]) => [ + fieldKey, + { + label: config.label, + value: config.resolve(ds, {acceptedIdentification, primaryEvent}), + isHtml: Boolean(config.isHtml), + type: config.type || 'base', + hidden: config.hidden || false + } + ]) + ); + + return [groupKey, mappedFields]; + }) + ) as unknown as DigitalSpecimenUIModel; +}; diff --git a/src/utils/DataMappers/schemas/DigitalSpecimenSchema.ts b/src/utils/DataMappers/schemas/DigitalSpecimenSchema.ts new file mode 100644 index 000000000..7946000d3 --- /dev/null +++ b/src/utils/DataMappers/schemas/DigitalSpecimenSchema.ts @@ -0,0 +1,220 @@ +import { FieldConfig } from "../types/dataMapperTypes"; + +/* Data schema to map Digital Specimen data that we use in the UI, managed from one central data mapper */ +const DIGITAL_SPECIMEN_SCHEMA_MAP: Record> = { + SPECIMEN_RECORD: { + doi: { + label: 'DOI', + resolve: (ds) => ds["@id"], + type: 'copy' + }, + catalogNumber: { + label: 'Catalog Number', + resolve: (ds) => { + const catalog = ds["ods:hasIdentifiers"]?.find((id: any) => id["dcterms:title"] === "dwc:catalogNumber"); + const fallBack = ds["ods:hasIdentifiers"]?.find((id: any) => id["dcterms:title"] === "dwca:ID"); + return catalog?.["dwc:catalogNumber"] || fallBack?.["dwca:id"]; + } + }, + specimenProvider: { + label: 'Specimen Provider', + resolve: (ds) => ds["ods:organisationName"] + }, + sourceSystem: { + label: 'Source System', + resolve: (ds) => ds["ods:sourceSystemName"] + }, + basisOfRecord: { + label: 'Basis of Record', + resolve: (ds) => ds["dwc:basisOfRecord"] + }, + discipline: { + label: 'Discipline', + resolve: (ds) => ds["dwc:topicDiscipline"] + } + }, + + IDENTIFICATION: { + scientificName: { + label: 'Scientific Name', + resolve: (_, { acceptedIdentification }) => { + const htmlLabel = acceptedIdentification?.["ods:scientificNameHTMLLabel"]; + const fallbackLabel = acceptedIdentification?.["dwc:scientificName"]; + + return htmlLabel || fallbackLabel; + }, + }, + verbatimName: { + label: 'Identification Verbatim', + type: 'verbatim', + resolve: (ds) => ds["ods:hasIdentifications"]?.[0]?.["dwc:verbatimIdentification"] + }, + rank: { + label: 'Rank', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:taxonRank"] + }, + taxonomicStatus: { + label: 'Taxonomic Status', + type: 'url', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["@id"] + }, + kingdom: { + label: 'Kingdom', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:kingdom"] + }, + phylum: { + label: 'Phylum', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:phylum"] + }, + class: { + label: 'Class', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:class"] + }, + order: { + label: 'Order', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:order"] + }, + family: { + label: 'Family', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:family"] + }, + subFamily: { + label: 'Sub-family', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:subfamily"] + }, + genus: { + label: 'Genus', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:genus"] + }, + species: { + label: 'Species', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:species"] + }, + specificEpithet: { + label: 'Specific Epithet', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:specificEpithet"] + }, + infragenericEpithet: { + label: 'Infrageneric Epithet', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:infragenericEpithet"] + }, + infraspecificEpithet: { + label: 'Infraspecific Epithet', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:infraspecificEpithet"] + }, + nomenClaturalCode: { + label: 'Nomenclatural Code', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:nomenclaturalCode"] + }, + scientificNameAuthorship: { + label: 'Scientific Name Authorship', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:scientificNameAuthorship"] + }, + sex: { + label: 'Sex', + resolve: (_, { primaryEvent }) => primaryEvent?.["dwc:sex"] + }, + lifeStage: { + label: 'Life Stage', + resolve: (_, { primaryEvent }) => primaryEvent?.["dwc:lifeStage"] + } + }, + + LOCATION: { + country: { + label: 'Country', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["dwc:country"] + }, + locality: { + label: 'Locality', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["dwc:locality"] + }, + verbatimLocality: { + label: 'Locality Verbatim', + type: 'verbatim', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["dwc:verbatimLocality"], + }, + geodeticDatum: { + label: 'Geodetic Datum', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["ods:hasGeoreference"]?.["dwc:geodeticDatum"] + }, + decimalLongitude: { + label: 'Decimal Longitude', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["ods:hasGeoreference"]?.["dwc:decimalLongitude"] + }, + verbatimLongitude: { + label: 'Decimal Longitude', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["ods:hasGeoreference"]?.["dwc:verbatimLongitude"], + type: 'verbatim' + }, + decimalLatitude: { + label: 'Decimal Latitude', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["ods:hasGeoreference"]?.["dwc:decimalLatitude"] + }, + verbatimLatitude: { + label: 'Decimal Latitude', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasLocation"]?.["ods:hasGeoreference"]?.["dwc:verbatimLatitude"], + type: 'verbatim' + }, + }, + + COLLECTING_EVENT: { + collector: { + label: 'Collector', + resolve: (_, { primaryEvent }) => primaryEvent?.["ods:hasAgents"]?.[0]?.["schema:name"] + }, + date: { + label: 'Date', + resolve: (_, { primaryEvent }) => primaryEvent?.["dwc:eventDate"] + }, + verbatimDate: { + label: 'Date verbatim', + resolve: (_, { primaryEvent }) => primaryEvent?.["dwc:verbatimEventDate"], + type: 'verbatim' + } + }, + + CITATION_LICENSE: { + license: { + label: 'License Agreement', + resolve: (ds) => ds["dcterms:license"] + }, + organisationId: { + label: 'Organisation ID', + resolve: (ds) => ds["ods:organisationID"], + hidden: true + }, + organisationName: { + label: 'Organisation Name', + resolve: (ds) => ds["ods:organisationName"], + hidden: true + }, + scientificName: { + label: 'Scientific Name', + resolve: (_, { acceptedIdentification }) => { + const htmlLabel = acceptedIdentification?.["ods:scientificNameHTMLLabel"]; + const fallbackLabel = acceptedIdentification?.["dwc:scientificName"]; + + return htmlLabel || fallbackLabel; + }, + hidden: true + }, + digitalSpecimenId: { + label: 'Digital Specimen ID', + resolve: (ds) => ds["@id"], + hidden: true + } + }, + UI_COMPONENTS_DATA: { + taxonRank: { + label: 'Taxonomic Rank', + resolve: (_, { acceptedIdentification }) => acceptedIdentification?.["dwc:taxonRank"] + }, + typeStatus: { + label: 'Type Status', + resolve: (ds) => ds["ods:hasIdentifications"]?.[0]?.["dwc:typeStatus"] + } + } +}; + +export default DIGITAL_SPECIMEN_SCHEMA_MAP; \ No newline at end of file diff --git a/src/utils/DataMappers/types/dataMapperTypes.ts b/src/utils/DataMappers/types/dataMapperTypes.ts new file mode 100644 index 000000000..2967745a7 --- /dev/null +++ b/src/utils/DataMappers/types/dataMapperTypes.ts @@ -0,0 +1,40 @@ +/* MapperContext to set the context of the data, e.g. acceptedIdentification */ +interface MapperContext { + acceptedIdentification?: any; + primaryEvent?: any; +} + +/* UI Property interface to map data */ +interface UIProperty { + label: string; + value: any; + isHtml: boolean; + type: string; + hidden: boolean; +} + +/* Corresponding field config interface for DigitalSpecimen schema */ +interface FieldConfig { + label: string; + resolve: (ds: any, context: MapperContext) => any; + isHtml?: boolean; + type?: string; + hidden?: boolean; +} + +/* Result of the Digital Specimen data mapper */ +interface DigitalSpecimenUIModel { + SPECIMEN_RECORD: Record; + IDENTIFICATION: Record; + LOCATION: Record; + COLLECTING_EVENT: Record; + CITATION_LICENSE: Record; +} + +/* Generic map to handle schemas for the digitalSpecimenUIModel */ +type SchemaMap = { + [Key in keyof DigitalSpecimenUIModel]: Record; +}; + +export type { UIProperty, FieldConfig, DigitalSpecimenUIModel, SchemaMap }; +