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 };
+