From 83beeeebc347f82cd6dbf31735a52cbfd6ed3066 Mon Sep 17 00:00:00 2001 From: Javier Romero Castro Date: Sun, 14 Dec 2025 15:24:59 +0100 Subject: [PATCH] feat: introduce linked records section on record detail pages * closes https://github.com/CERNDocumentServer/cds-rdm/issues/631 --- .../linked-records/LinkedRecordsSearch.js | 113 ++++++++++++++++++ .../js/cds_rdm/linked-records/api/config.js | 37 ++++++ .../linked-records/components/Filter.js | 77 ++++++++++++ .../linked-records/components/NoResults.js | 19 +++ .../linked-records/components/RecordItem.js | 73 +++++++++++ .../components/RelatedRecordsResultsLoader.js | 26 ++++ .../components/ResultsLayout.js | 20 ++++ .../js/cds_rdm/linked-records/index.js | 25 ++++ site/cds_rdm/ext.py | 5 + .../semantic-ui/cds_rdm/records/detail.html | 59 +++++++-- site/cds_rdm/views.py | 61 ++++++++++ site/cds_rdm/webpack.py | 1 + 12 files changed, 505 insertions(+), 11 deletions(-) create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/LinkedRecordsSearch.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/api/config.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/Filter.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/NoResults.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RecordItem.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/RelatedRecordsResultsLoader.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/components/ResultsLayout.js create mode 100644 site/cds_rdm/assets/semantic-ui/js/cds_rdm/linked-records/index.js 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 ( +
+
{containerCmp}
+
+
+
+ ); +}; + +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} + + + ); +}; + +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 */} +
+ + + + + +
+ + {/* 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 %}
- -
- {{ _("To loan this literature, see Library holdings in the") }} - - {{ _("CERN Library Catalogue") }} - - {{ _("website.") }} -
+ +
+ {{ _("To loan this literature, see Library holdings in the") }} + + {{ _("CERN Library Catalogue") }} + + {{ _("website.") }} +
{% 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", }, ), },