From ce4eb01680182fa775e5fb8b7bfef0bd5f892f18 Mon Sep 17 00:00:00 2001 From: Stephan Heunis Date: Fri, 13 Feb 2026 15:18:51 +0100 Subject: [PATCH] New 'Special Button' feature for record viewer Records might have properties (via RDF statements) that are best viewed as a button to click on, e.g. a download URL or a link to an external homepage or similar. This new feature allows buttons to be rendered for configured 'special properties', where the configuration includes identifying the special property, how to find it (relative to the parent record), which exact value to find, and how to turn it into a link. For example: component_config: NodeShapeViewer: specialButtons: DownloadUrl: slot: dlthings:characterized_by match: - key: rdf:type val: dlthings:Statement - key: rdf:predicate val: dcat:downloadUrl return: rdf:object template: '{return}' icon: mdi-download tooltip: Download This configuration specifies the new config option 'specialButtons' under 'component_config -> NodeShapeViewer'. It configures the 'DownloadUrl' special button: - 'slot: dlthings:characterized_by': start with the 'dlthings:characterized_by' slot/property of the main record - for every related record referenced via the 'dlthings:characterized_by' slot, match them against keys and values provided in the 'match' list. This means: find the related record for which the 'rdf:type' is 'dlthings:Statement' AND for which the 'rdf:predicate' is 'dcat:downloadUrl'. The possibility of matching multiple records is supported. - for every matched record, 'return' the 'rdf:object' (multiple return values per matched record is not yet supported) - To ultimately get the link/href value(s), pass the returned value(s) together with the 'template' into the already existing 'fillStringTemplate' function that performs string serialization The config should also contain an icon and tooltip in order to render a user-friendly button. To achieve this, the main code changes include: - add the main work horse function to utils: 'findBlankNodeLink', this interprets the config and finds the relevant information - update 'NodeShapeViewer' to call the new function if any 'specialButtons' are configured - new comoponent 'SpecialButton' is rendered from 'NodeShapeViewer'; it fills the string template with matched values as input, and renders the buttons - the 'getIcon' function was generlized and moved to utils, and removed from 'useWizard' composable - 'SVGIcon' component has updated styling to render an icon vertical center NOTE: an additional unrelated change to 'NodeShapeViewer' was also committed: this uses 'Promise.all' instead of a for loop to call 'addRecordProperty' in an attempt to shorten the amount of time needed to render a single record. --- src/components/NodeShapeViewer.vue | 42 +++++++++++++++--- src/components/SVGIcon.vue | 9 ++++ src/components/SpecialButton.vue | 63 +++++++++++++++++++++++++++ src/composables/useWizard.js | 31 ++------------ src/modules/utils.js | 68 ++++++++++++++++++++++++++++++ 5 files changed, 180 insertions(+), 33 deletions(-) create mode 100644 src/components/SpecialButton.vue diff --git a/src/components/NodeShapeViewer.vue b/src/components/NodeShapeViewer.vue index e252e1d..68bef92 100644 --- a/src/components/NodeShapeViewer.vue +++ b/src/components/NodeShapeViewer.vue @@ -17,7 +17,7 @@ > - + Type: {{ toCURIE(record.subtitle, allPrefixes) }}  
@@ -66,6 +66,12 @@ + + +   + + +
@@ -309,9 +315,12 @@ import { getNodeShapePropertyWithAnnotations, getSubjectQuad, getDisplayName, + quadsToTripleObject, + findBlankNodeLink, } from '../modules/utils'; import { RDF, SHACL } from '@/modules/namespaces'; import MoreOrLessRecordsViewer from './MoreOrLessRecordsViewer.vue'; +import SpecialButton from '@/components/SpecialButton.vue' import { useCompConfig } from '@/composables/useCompConfig'; import { useDisplay } from 'vuetify' const { mobile } = useDisplay() @@ -367,7 +376,9 @@ const ttlDialog_name = ref(''); const ttlDialog_type = ref(''); const ttlDialog_content = ref(''); const fetchingRecords = ref(false); -const canEditClass = ref(false) +const canEditClass = ref(false); +const showSpecialButtons = ref(false); +const specialButtons = reactive({}); const emit = defineEmits(['namedNodeSelected']); function selectNamedNode(recordClass, recordPID) { @@ -465,9 +476,11 @@ async function updateRecord(fetchData, from) { BlankNode: {}, NamedNode: {}, }; - for (const rQ of record.relatedQuads) { - await addRecordProperty(rQ, fetchData); - } + const promises = record.relatedQuads.map(rQ => + addRecordProperty(rQ, fetchData) + ) + await Promise.all(promises) + const end = performance.now() record.displayLabel = getRecordDisplayLabel(record.quad.subject, rdfDS, allPrefixes, configVarsMain) // Now we have all record.triples, and we need to get displaylabels for blanknodes for (const triplePred in record.triples['BlankNode']) { @@ -482,6 +495,22 @@ async function updateRecord(fetchData, from) { record.triples['BlankNode'][triplePred].prefLabels.push(pL) } } + // Now let's check for clickable data + if (componentConfig?.specialButtons && typeof componentConfig?.specialButtons === 'object' + && Object.keys(componentConfig?.specialButtons).length > 0 + ) { + for (const sB of Object.keys(componentConfig?.specialButtons)) { + let foundSB = findBlankNodeLink(record, componentConfig.specialButtons[sB], allPrefixes) + if (foundSB) { + specialButtons[sB] = {}; + specialButtons[sB].returnValue = foundSB; + specialButtons[sB].config = componentConfig.specialButtons[sB]; + } + } + if (Object.keys(specialButtons).length) { + showSpecialButtons.value = true; + } + } } async function addRecordProperty(quad, fetchData) { @@ -503,6 +532,7 @@ async function addRecordProperty(quad, fetchData) { displayLabels: [], prefLabels: [], keyPropertyRoles: [], + relatedTriples: [], }; } let kpr = null @@ -511,6 +541,8 @@ async function addRecordProperty(quad, fetchData) { let keyPropertyShape = getNodeShapePropertyWithAnnotations(ps[SHACL.class.value], shapesDS, {"dash:propertyRole": "dash:KeyInfoRole"}, allPrefixes) let keyPropertyRole = keyPropertyShape ? keyPropertyShape[SHACL.path.value] : null let bnRelatedQuads = rdfDS.getSubjectTriples(quad.object); + let relatedTriples = quadsToTripleObject(bnRelatedQuads, allPrefixes) + record.triples[termType][quad.predicate.value]['relatedTriples'].push(relatedTriples) for (const bnQuad of bnRelatedQuads) { if (bnQuad.object.termType === 'NamedNode') { console.log("Also fetching blank node object record:") diff --git a/src/components/SVGIcon.vue b/src/components/SVGIcon.vue index de82323..9d02021 100644 --- a/src/components/SVGIcon.vue +++ b/src/components/SVGIcon.vue @@ -18,9 +18,18 @@ diff --git a/src/components/SpecialButton.vue b/src/components/SpecialButton.vue new file mode 100644 index 0000000..296d6ad --- /dev/null +++ b/src/components/SpecialButton.vue @@ -0,0 +1,63 @@ + + + \ No newline at end of file diff --git a/src/composables/useWizard.js b/src/composables/useWizard.js index 1db2c13..26fb402 100644 --- a/src/composables/useWizard.js +++ b/src/composables/useWizard.js @@ -1,5 +1,5 @@ import { ref, reactive } from "vue"; -import { getContent, fillStringTemplate, findObjectByKey, findObjectIndexByKey, nodeShapeHasProperty} from "@/modules/utils"; +import { getContent, fillStringTemplate, findObjectByKey, findObjectIndexByKey, nodeShapeHasProperty, getIcon} from "@/modules/utils"; import { toCURIE, toIRI } from "shacl-tulip"; import { RDF } from "@/modules/namespaces"; import { DataFactory } from 'n3'; @@ -55,7 +55,7 @@ export function useWizard() { console.log(`adding wizard '${wizard}' for class '${classCurie}' and context '${context}'`) wizardEditors[wizard] = configVarsMain.wizardEditors[wizard] wizardEditors[wizard].template = getContent(configVarsMain.content, wizardEditors[wizard].template) - wizardEditors[wizard].iconFig = getIcon(wizardEditors[wizard], configVarsMain) + wizardEditors[wizard].iconFig = getIcon(wizardEditors[wizard].icon, configVarsMain) } } // then slot-based wizards @@ -67,7 +67,7 @@ export function useWizard() { if (wizard in wizardEditors) continue; wizardEditors[wizard] = configVarsMain.wizardEditors[wizard] wizardEditors[wizard].template = getContent(configVarsMain.content, wizardEditors[wizard].template) - wizardEditors[wizard].iconFig = getIcon(wizardEditors[wizard], configVarsMain) + wizardEditors[wizard].iconFig = getIcon(wizardEditors[wizard].icon, configVarsMain) } } } @@ -160,31 +160,6 @@ export function useWizard() { } } - function getIcon(wizard, configVarsMain) { - if (wizard.icon) { - if (wizard.icon.startsWith('mdi-')) { - return { - type: 'mdi', - icon: wizard.icon - } - } else if (wizard.icon.startsWith('content:')) { - return { - type: 'svg', - icon: getContent(configVarsMain.content, wizard.icon) - } - } else { - return { - type: 'svg', - icon: wizard.icon - } - } - } - return { - type: 'mdi', - icon: 'mdi-plus-box' - } - } - function onFormWithWizardCancel(savedNodes, nodesToSubmit, rdfDS) { for (const q of wizardAddedQuads.value) { // remove named nodes from savedNodes and nodesToSubmit diff --git a/src/modules/utils.js b/src/modules/utils.js index df14d6b..d24a913 100644 --- a/src/modules/utils.js +++ b/src/modules/utils.js @@ -851,4 +851,72 @@ export function updatePropertyGroups(configVarsMain, shapesDS) { shapesDS.data.propertyGroups['_default'] = {}; shapesDS.data.propertyGroups['_default'][RDFS.label.value] = "Additional properties"; shapesDS.data.propertyGroups['_default'][SHACL.order.value] = high_order + 100; +} + +export function findBlankNodeLink(data, config, allPrefixes) { + + const { slot, match = [], return: returnKey } = config; + + let slotIRI = toIRI(slot, allPrefixes) + + // 1. Check that the slot exists + const blankNodes = data?.triples?.BlankNode; + if (!blankNodes || !blankNodes[slotIRI]) { + return undefined; + } + + const relatedTriples = blankNodes[slotIRI]?.relatedTriples; + if (!Array.isArray(relatedTriples)) { + return undefined; + } + console.log(relatedTriples) + + // 2. Filter triples that satisfy ALL match conditions + const matches = relatedTriples.filter(triple => { + return match.every(({ key, val }) => { + const keyIRI = toIRI(key, allPrefixes) + console.log(key) + const valIRI = toIRI(val, allPrefixes); + console.log(valIRI) + const tripleValue = triple[key]; + console.log(tripleValue) + // key must exist and value must be an array containing val + return Array.isArray(tripleValue) && tripleValue.includes(valIRI); + }); + }); + + if (matches.length === 0) { + return undefined; + } + + // 3. Return the requested key values + const results = matches + .map(triple => triple[returnKey]) + .filter(Boolean) // remove undefined + .flat(); // flatten arrays like ["url"] + + return results.length ? results : undefined; +} + +export function getIcon(iconText, configVarsMain, defaultIcon={type: 'mdi',icon: 'mdi-plus-box'}) { + if (iconText) { + if (iconText.startsWith('mdi-')) { + return { + type: 'mdi', + icon: iconText + } + } else if (iconText.startsWith('content:')) { + return { + type: 'svg', + icon: getContent(configVarsMain.content, iconText) + } + } else { + return { + type: 'svg', + icon: iconText + } + } + } else { + return defaultIcon + } } \ No newline at end of file -- 2.52.0