New Special Button feature for record viewer #335

Merged
jsheunis merged 1 commit from special-buttons into main 2026-02-13 14:24:55 +00:00
5 changed files with 180 additions and 33 deletions

View file

@ -66,6 +66,12 @@
</template> </template>
</v-tooltip> </v-tooltip>
</span> </span>
<span v-if="showSpecialButtons">
<span v-for="button in specialButtons">
&nbsp;
<SpecialButton :returnVal="button.returnValue" :config="button.config"></SpecialButton>
</span>
</span>
</span> </span>
</v-card-subtitle> </v-card-subtitle>
<v-card-text v-if="!props.formOpen" :class="mobile ? 'text-caption' : ''"> <v-card-text v-if="!props.formOpen" :class="mobile ? 'text-caption' : ''">
@ -309,9 +315,12 @@ import {
getNodeShapePropertyWithAnnotations, getNodeShapePropertyWithAnnotations,
getSubjectQuad, getSubjectQuad,
getDisplayName, getDisplayName,
quadsToTripleObject,
findBlankNodeLink,
} from '../modules/utils'; } from '../modules/utils';
import { RDF, SHACL } from '@/modules/namespaces'; import { RDF, SHACL } from '@/modules/namespaces';
import MoreOrLessRecordsViewer from './MoreOrLessRecordsViewer.vue'; import MoreOrLessRecordsViewer from './MoreOrLessRecordsViewer.vue';
import SpecialButton from '@/components/SpecialButton.vue'
import { useCompConfig } from '@/composables/useCompConfig'; import { useCompConfig } from '@/composables/useCompConfig';
import { useDisplay } from 'vuetify' import { useDisplay } from 'vuetify'
const { mobile } = useDisplay() const { mobile } = useDisplay()
@ -367,7 +376,9 @@ const ttlDialog_name = ref('');
const ttlDialog_type = ref(''); const ttlDialog_type = ref('');
const ttlDialog_content = ref(''); const ttlDialog_content = ref('');
const fetchingRecords = ref(false); const fetchingRecords = ref(false);
const canEditClass = ref(false) const canEditClass = ref(false);
const showSpecialButtons = ref(false);
const specialButtons = reactive({});
const emit = defineEmits(['namedNodeSelected']); const emit = defineEmits(['namedNodeSelected']);
function selectNamedNode(recordClass, recordPID) { function selectNamedNode(recordClass, recordPID) {
@ -465,9 +476,11 @@ async function updateRecord(fetchData, from) {
BlankNode: {}, BlankNode: {},
NamedNode: {}, NamedNode: {},
}; };
for (const rQ of record.relatedQuads) { const promises = record.relatedQuads.map(rQ =>
await addRecordProperty(rQ, fetchData); addRecordProperty(rQ, fetchData)
} )
await Promise.all(promises)
const end = performance.now()
record.displayLabel = getRecordDisplayLabel(record.quad.subject, rdfDS, allPrefixes, configVarsMain) record.displayLabel = getRecordDisplayLabel(record.quad.subject, rdfDS, allPrefixes, configVarsMain)
// Now we have all record.triples, and we need to get displaylabels for blanknodes // Now we have all record.triples, and we need to get displaylabels for blanknodes
for (const triplePred in record.triples['BlankNode']) { for (const triplePred in record.triples['BlankNode']) {
@ -482,6 +495,22 @@ async function updateRecord(fetchData, from) {
record.triples['BlankNode'][triplePred].prefLabels.push(pL) 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) { async function addRecordProperty(quad, fetchData) {
@ -503,6 +532,7 @@ async function addRecordProperty(quad, fetchData) {
displayLabels: [], displayLabels: [],
prefLabels: [], prefLabels: [],
keyPropertyRoles: [], keyPropertyRoles: [],
relatedTriples: [],
}; };
} }
let kpr = null 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 keyPropertyShape = getNodeShapePropertyWithAnnotations(ps[SHACL.class.value], shapesDS, {"dash:propertyRole": "dash:KeyInfoRole"}, allPrefixes)
let keyPropertyRole = keyPropertyShape ? keyPropertyShape[SHACL.path.value] : null let keyPropertyRole = keyPropertyShape ? keyPropertyShape[SHACL.path.value] : null
let bnRelatedQuads = rdfDS.getSubjectTriples(quad.object); 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) { for (const bnQuad of bnRelatedQuads) {
if (bnQuad.object.termType === 'NamedNode') { if (bnQuad.object.termType === 'NamedNode') {
console.log("Also fetching blank node object record:") console.log("Also fetching blank node object record:")

View file

@ -18,9 +18,18 @@
</script> </script>
<style scoped> <style scoped>
.custom-svg {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
}
.custom-svg svg { .custom-svg svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block;
fill: currentColor; fill: currentColor;
} }
</style> </style>

View file

@ -0,0 +1,63 @@
<template>
<v-tooltip v-for="b in btns" :text="props.config.tooltip" location="bottom">
<template v-slot:activator="{ props }">
<v-btn
icon
variant="tonal"
size="x-small"
class="rounded-lg"
@click="openInNewTab(b.link)"
v-bind="props"
style="cursor: pointer;"
>
<span v-if="b.iconFig.type == 'mdi'">
<v-icon>{{ b.iconFig.icon }}</v-icon>
</span>
<span v-else>
<SVGIcon :icon="b.iconFig.icon"></SVGIcon>
</span>
</v-btn>
</template>
</v-tooltip>
</template>
<script setup>
import { ref, onBeforeMount, onMounted, inject, toRaw } from 'vue';
import SVGIcon from '@/components/SVGIcon.vue'
import { fillStringTemplate, getIcon} from '@/modules/utils';
// --------------- //
// Component props //
// --------------- //
const props = defineProps({
returnVal: Array,
config: Object,
});
// ---- //
// Data //
// ---- //
const btns = ref([]);
const configVarsMain = inject('configVarsMain')
// ----------------- //
// Lifecycle methods //
// ----------------- //
onBeforeMount(() => {
for (const sBval of props.returnVal) {
let btn = {}
btn.link = fillStringTemplate(props.config.template, {'return': sBval})
btn.iconFig = getIcon(props.config.icon, configVarsMain)
btns.value.push(btn)
}
});
// --------- //
// Functions //
// --------- //
function openInNewTab(url) {
window.open(url, '_blank').focus();
}
</script>

View file

@ -1,5 +1,5 @@
import { ref, reactive } from "vue"; 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 { toCURIE, toIRI } from "shacl-tulip";
import { RDF } from "@/modules/namespaces"; import { RDF } from "@/modules/namespaces";
import { DataFactory } from 'n3'; import { DataFactory } from 'n3';
@ -55,7 +55,7 @@ export function useWizard() {
console.log(`adding wizard '${wizard}' for class '${classCurie}' and context '${context}'`) console.log(`adding wizard '${wizard}' for class '${classCurie}' and context '${context}'`)
wizardEditors[wizard] = configVarsMain.wizardEditors[wizard] wizardEditors[wizard] = configVarsMain.wizardEditors[wizard]
wizardEditors[wizard].template = getContent(configVarsMain.content, wizardEditors[wizard].template) 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 // then slot-based wizards
@ -67,7 +67,7 @@ export function useWizard() {
if (wizard in wizardEditors) continue; if (wizard in wizardEditors) continue;
wizardEditors[wizard] = configVarsMain.wizardEditors[wizard] wizardEditors[wizard] = configVarsMain.wizardEditors[wizard]
wizardEditors[wizard].template = getContent(configVarsMain.content, wizardEditors[wizard].template) 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) { function onFormWithWizardCancel(savedNodes, nodesToSubmit, rdfDS) {
for (const q of wizardAddedQuads.value) { for (const q of wizardAddedQuads.value) {
// remove named nodes from savedNodes and nodesToSubmit // remove named nodes from savedNodes and nodesToSubmit

View file

@ -852,3 +852,71 @@ export function updatePropertyGroups(configVarsMain, shapesDS) {
shapesDS.data.propertyGroups['_default'][RDFS.label.value] = "Additional properties"; shapesDS.data.propertyGroups['_default'][RDFS.label.value] = "Additional properties";
shapesDS.data.propertyGroups['_default'][SHACL.order.value] = high_order + 100; 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
}
}