diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/LinkedRecordsSearch.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/LinkedRecordsSearch.js
new file mode 100644
index 00000000..a58e4d92
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/LinkedRecordsSearch.js
@@ -0,0 +1,113 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import PropTypes from "prop-types";
+import {
+ ReactSearchKit,
+ InvenioSearchApi,
+ ResultsLoader,
+ ResultsMultiLayout,
+ Error,
+ EmptyResults,
+ Pagination,
+ BucketAggregation,
+ SearchBar,
+} from "react-searchkit";
+import { OverridableContext } from "react-overridable";
+import { apiConfig } from "./api/config";
+import { Segment, Container, Grid } from "semantic-ui-react";
+import { ResultsListLayout } from "./components/ResultsLayout";
+import { RecordListItem } from "./components/RecordItem";
+import { FilterContainer, Filter, FilterValues } from "./components/Filter";
+import { NoResults } from "./components/NoResults";
+import { RelatedRecordsResultsLoader } from "./components/RelatedRecordsResultsLoader";
+
+const linkedRecordsSearchAppID = "linkedRecordsSearch";
+
+const overriddenComponents = {
+ [`${linkedRecordsSearchAppID}.ResultsList.container`]: ResultsListLayout,
+ [`${linkedRecordsSearchAppID}.ResultsList.item`]: RecordListItem,
+ [`${linkedRecordsSearchAppID}.BucketAggregation.element`]: FilterContainer,
+ [`${linkedRecordsSearchAppID}.BucketAggregationContainer.element`]: Filter,
+ [`${linkedRecordsSearchAppID}.BucketAggregationValues.element`]: FilterValues,
+ [`${linkedRecordsSearchAppID}.EmptyResults.element`]: NoResults,
+ [`${linkedRecordsSearchAppID}.ResultsLoader.element`]: RelatedRecordsResultsLoader,
+};
+
+export const LinkedRecordsSearch = ({ endpoint, searchQuery }) => {
+ // Pass the base query to apiConfig so it can be handled by the request interceptor
+ const searchApi = new InvenioSearchApi(apiConfig(endpoint, searchQuery));
+
+ const initialState = {
+ queryString: "", // Keep search bar empty for user input
+ sortBy: "bestmatch",
+ sortOrder: "asc",
+ page: 1,
+ size: 5,
+ layout: "list",
+ };
+
+ return (
+
+
+ <>
+ {/* Search Bar and Controls */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Results */}
+
+
+
+
+
+
+
+
+
+
+ >
+
+
+ );
+};
+
+LinkedRecordsSearch.propTypes = {
+ endpoint: PropTypes.string.isRequired,
+ searchQuery: PropTypes.string.isRequired,
+};
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/api/config.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/api/config.js
new file mode 100644
index 00000000..a99a1603
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/api/config.js
@@ -0,0 +1,37 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+export const apiConfig = (endpoint, baseQuery) => ({
+ axios: {
+ url: endpoint,
+ timeout: 5000,
+ headers: {
+ Accept: "application/vnd.inveniordm.v1+json",
+ },
+ },
+ interceptors: {
+ request: {
+ resolve: (config) => {
+ // Modify the params to combine base query with user search
+ if (config.params) {
+ const userQuery = config.params.queryString;
+
+ // Combine base query with user search
+ if (baseQuery && userQuery) {
+ config.params.queryString = `(${baseQuery}) AND (${userQuery})`;
+ } else if (baseQuery) {
+ config.params.queryString = baseQuery;
+ }
+ // If only userQuery exists, leave it as is (though this shouldn't happen)
+ }
+
+ return config;
+ },
+ reject: (error) => Promise.reject(error),
+ },
+ },
+});
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/Filter.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/Filter.js
new file mode 100644
index 00000000..d791d691
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/Filter.js
@@ -0,0 +1,77 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import { PropTypes } from "prop-types";
+import { Dropdown, Label, Button, Icon } from "semantic-ui-react";
+import { withState } from "react-searchkit";
+
+export const FilterContainer = ({ agg, containerCmp, updateQueryFilters }) => {
+ const clearFacets = () => {
+ if (containerCmp.props.selectedFilters.length) {
+ updateQueryFilters([agg.aggName, ""], containerCmp.props.selectedFilters);
+ }
+ };
+
+ return (
+
+ );
+};
+
+FilterContainer.propTypes = {
+ agg: PropTypes.object.isRequired,
+ updateQueryFilters: PropTypes.func.isRequired,
+ containerCmp: PropTypes.node.isRequired,
+};
+
+export const Filter = withState(({ currentQueryState, valuesCmp }) => {
+ const numSelectedFilters = currentQueryState.filters.length;
+ return (
+
+ {valuesCmp}
+
+ );
+});
+
+Filter.propTypes = {
+ valuesCmp: PropTypes.array.isRequired,
+};
+
+export const FilterValues = ({ bucket, isSelected, onFilterClicked, label }) => {
+ return (
+ onFilterClicked(bucket.key)}
+ value={bucket.key}
+ className="flex align-items-center justify-space-between"
+ >
+ {isSelected && }
+
+ {label}
+
+ {bucket.doc_count.toLocaleString("en-US")}
+
+
+ );
+};
+
+FilterValues.propTypes = {
+ bucket: PropTypes.object.isRequired,
+ isSelected: PropTypes.bool.isRequired,
+ onFilterClicked: PropTypes.func.isRequired,
+ label: PropTypes.string.isRequired,
+};
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/NoResults.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/NoResults.js
new file mode 100644
index 00000000..1b7e4a51
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/NoResults.js
@@ -0,0 +1,19 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import { Container } from "semantic-ui-react";
+
+export const NoResults = () => {
+ return (
+
+
+ No related content for this record
+
+
+ );
+};
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RecordItem.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RecordItem.js
new file mode 100644
index 00000000..7d433dfd
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RecordItem.js
@@ -0,0 +1,73 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import PropTypes from "prop-types";
+import { Item, Label } from "semantic-ui-react";
+import _get from "lodash/get";
+import { SearchItemCreators } from "@js/invenio_app_rdm/utils";
+
+
+const layoutProps = (result) => {
+ return {
+ accessStatusId: _get(result, "ui.access_status.id", "open"),
+ accessStatus: _get(result, "ui.access_status.title_l10n", "Open"),
+ accessStatusIcon: _get(result, "ui.access_status.icon", "unlock"),
+ createdDate: _get(result, "ui.created_date_l10n_long", "Unknown date"),
+ creators: _get(result, "ui.creators.creators", []).slice(0, 3),
+ resourceType: _get(result, "ui.resource_type.title_l10n", "No resource type"),
+ title: _get(result, "metadata.title", "No title"),
+ link: _get(result, "links.self_html", "#"),
+ };
+};
+
+export const RecordListItem = ({ result }) => {
+ const {
+ accessStatusId,
+ accessStatus,
+ createdDate,
+ creators,
+ resourceType,
+ title,
+ link,
+ } = layoutProps(result);
+
+ return (
+ -
+
+ {/* Metadata badges */}
+
+
+ {resourceType}
+
+
+
+ {createdDate}
+
+
+
+ {accessStatus}
+
+
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Creators */}
+
+
+
+
+
+ );
+};
+
+RecordListItem.propTypes = {
+ result: PropTypes.object.isRequired,
+};
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RelatedRecordsResultsLoader.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RelatedRecordsResultsLoader.js
new file mode 100644
index 00000000..19dc0c75
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RelatedRecordsResultsLoader.js
@@ -0,0 +1,26 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import { Placeholder } from "semantic-ui-react";
+
+export const RelatedRecordsResultsLoader = (children, loading) => {
+ return loading ? (
+
+
+
+
+
+
+
+
+
+
+ ) : (
+ children
+ );
+};
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/ResultsLayout.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/ResultsLayout.js
new file mode 100644
index 00000000..5f1453dc
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/ResultsLayout.js
@@ -0,0 +1,20 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import PropTypes from "prop-types";
+import { Grid, Item } from "semantic-ui-react";
+
+export const ResultsListLayout = ({ results }) => (
+
+ {results}
+
+);
+
+ResultsListLayout.propTypes = {
+ results: PropTypes.array.isRequired,
+};
diff --git a/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/index.js b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/index.js
new file mode 100644
index 00000000..e7cb972a
--- /dev/null
+++ b/site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/index.js
@@ -0,0 +1,25 @@
+// -*- coding: utf-8 -*-
+//
+// Copyright (C) 2025 CERN.
+//
+// CDS-RDM is free software; you can redistribute it and/or modify it under
+// the terms of the MIT License; see LICENSE file for more details.
+
+import React from "react";
+import ReactDOM from "react-dom";
+import { LinkedRecordsSearch } from "./LinkedRecordsSearch";
+
+const linkedRecordsContainer = document.getElementById("cds-linked-records");
+
+if (linkedRecordsContainer) {
+ const endpoint = linkedRecordsContainer.dataset.apiEndpoint;
+ const searchQuery = linkedRecordsContainer.dataset.searchQuery;
+
+ ReactDOM.render(
+ ,
+ linkedRecordsContainer
+ );
+}
diff --git a/site/cds_rdm/ext.py b/site/cds_rdm/ext.py
index c1e6323f..c1b8f4ce 100644
--- a/site/cds_rdm/ext.py
+++ b/site/cds_rdm/ext.py
@@ -14,6 +14,7 @@
from . import config
from .utils import evaluate_permissions
+from .views import get_linked_records_search_query
class CDS_RDM_App(object):
@@ -36,6 +37,10 @@ def init_app(self, app):
self.init_resources(app)
app.jinja_env.globals["get_clc_sync_entry"] = get_clc_sync_entry
app.jinja_env.globals["evaluate_permissions"] = evaluate_permissions
+ # Register filter for building linked records search query
+ app.jinja_env.filters["get_linked_records_search_query"] = (
+ get_linked_records_search_query
+ )
return app
def init_services(self, app):
diff --git a/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html b/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html
index d53f89fe..fc49bae7 100644
--- a/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html
+++ b/site/cds_rdm/templates/semantic-ui/cds_rdm/records/detail.html
@@ -1,8 +1,8 @@
{#
- Copyright (C) 2025 CERN.
+Copyright (C) 2025 CERN.
- CDS-RDM is free software; you can redistribute it and/or modify it
- under the terms of the GPL-2.0 License; see LICENSE file for more details.
+CDS-RDM is free software; you can redistribute it and/or modify it
+under the terms of the GPL-2.0 License; see LICENSE file for more details.
#}
{%- extends "invenio_app_rdm/records/detail.html" %}
@@ -15,14 +15,51 @@
{{super()}}
{% if clc_sync_entry and clc_sync_entry.clc_url %}
{% endif %}
{%- endblock record_content -%}
+
+{%- block record_details -%}
+{# Linked Records Section - Only evaluate if record has related_identifiers #}
+{% if record.metadata.related_identifiers %}
+{%- set linkedRecordsQuery = record | get_linked_records_search_query %}
+{% if linkedRecordsQuery %}
+Linked records
+
+{% endif %}
+{% endif %}
+{{super()}}
+{%- endblock record_details -%}
+
+{%- block javascript -%}
+{{ super() }}
+{# Only load JS if we actually rendered the linked records section #}
+{% if record.metadata.related_identifiers %}
+{%- set linkedRecordsQuery = record | get_linked_records_search_query %}
+{% if linkedRecordsQuery %}
+{{ webpack['cds-rdm-linked-records.js'] }}
+{% endif %}
+{% endif %}
+{%- endblock javascript -%}
\ No newline at end of file
diff --git a/site/cds_rdm/views.py b/site/cds_rdm/views.py
index 6ebe4c03..9eec2cd8 100644
--- a/site/cds_rdm/views.py
+++ b/site/cds_rdm/views.py
@@ -15,6 +15,8 @@
from invenio_communities import current_communities
from invenio_i18n import _
+from .schemes import legacy_cds_pattern
+
blueprint = Blueprint("cds-rdm_ext", __name__)
@@ -60,3 +62,62 @@ def inspire_link_render(record):
)
)
return ret
+
+
+def get_linked_records_search_query(record):
+ """Build search query for linked records.
+
+ Returns a search query string to find:
+ 1. Records that this record references in related_identifiers (scheme="cds")
+ - For legacy numeric recids: searches both id and metadata.identifiers
+ - For new alphanumeric PIDs: searches only id
+ 2. Records that reference this record in their related_identifiers (scheme="cds")
+
+ This handles CDS migration where old numeric recids are stored in
+ metadata.identifiers.identifier when records get new PIDs.
+ """
+ # Get CDS identifiers from related_identifiers
+ related_identifiers = record.data["metadata"].get("related_identifiers", [])
+ cds_related_ids = [
+ rel_id.get("identifier")
+ for rel_id in related_identifiers
+ if rel_id.get("scheme") == "cds" and rel_id.get("identifier")
+ ]
+
+ # Build query parts
+ query_parts = []
+
+ # Part 1: Records that this record references (forward)
+ # Search by record id using the CDS identifier
+ for cds_id in cds_related_ids:
+ if legacy_cds_pattern.match(cds_id):
+ # Old numeric recid: Search both by id AND in metadata.identifiers
+ # This handles both non-migrated records (where id = recid)
+ # and migrated records (where recid is stored in identifiers)
+ # Must filter by scheme:cds to avoid matching other identifier types
+ query_parts.append(
+ f'(id:"{cds_id}" OR '
+ f'(metadata.identifiers.scheme:cds AND metadata.identifiers.identifier:"{cds_id}"))'
+ )
+ else:
+ # New alphanumeric PID: Search only by id (current behavior)
+ query_parts.append(f'id:"{cds_id}"')
+
+ # Part 2: Records that reference this record (reverse)
+ # Find records that have this record's CDS PIDs in their related_identifiers
+ record_id = record.data.get("id")
+ query_parts.append(
+ "(metadata.related_identifiers.scheme:cds AND "
+ f'metadata.related_identifiers.identifier:"{record_id}")'
+ )
+
+ if not query_parts:
+ return None
+
+ # Combine all query parts with OR
+ combined_query = " OR ".join(query_parts)
+
+ # Exclude the current record and only show published records
+ final_query = f'({combined_query}) AND is_published:true NOT id:"{record_id}"'
+
+ return final_query
diff --git a/site/cds_rdm/webpack.py b/site/cds_rdm/webpack.py
index 57df5fa4..49af8678 100644
--- a/site/cds_rdm/webpack.py
+++ b/site/cds_rdm/webpack.py
@@ -18,6 +18,7 @@
entry={
"gltf_previewer_js": "./js/cds_rdm/previewers/gltf-previewer.js",
"gltf_previewer_css": "./less/cds_rdm/previewers/gltf-previewer.less",
+ "cds-rdm-linked-records": "./js/cds_rdm/linked-records/index.js",
},
),
},