New approach to generating/maintaining list of records + ShaclVueStarter feature #349

Merged
jsheunis merged 2 commits from shaclvue-kickstarter into main 2026-05-13 21:54:18 +00:00
16 changed files with 1866 additions and 322 deletions

View file

@ -1,15 +1,23 @@
<template> <template>
<v-app> <v-app>
<ShaclVue></ShaclVue> <!-- <component :is="appVariant" /> -->
<!-- <ShaclVue :configUrl="confURL"></ShaclVue> --> <component :is="appVariant" :configUrl="confURL"></component>
</v-app> </v-app>
</template> </template>
<script setup> <script setup>
import { defineAsyncComponent } from 'vue';
// A specific config URL can be provided: // A specific config URL can be provided:
// const confURL = ''; // const confURL = '';
// If not provided, the default config URLs will be tried in order at the base URL: // If not provided, the default config URLs will be tried in order at the base URL:
// 1. config.yaml // 1. config.yaml
// 2. config.yml // 2. config.yml
// 3. config.json // 3. config.json
// Now we set the main component based on VITE_SHACLVUE_VARIANT environment variable
const variant = import.meta.env.VITE_SHACLVUE_VARIANT
const componentMap = {
default: defineAsyncComponent(() => import('@/components/ShaclVue.vue')),
starter: defineAsyncComponent(() => import('@/components/ShaclVueStarter.vue')),
}
const appVariant = componentMap[variant] || componentMap.default;
</script> </script>

View file

@ -2,7 +2,7 @@ import { reactive, ref } from 'vue';
import { RdfDataset } from 'shacl-tulip'; import { RdfDataset } from 'shacl-tulip';
import { DataFactory, Store, Parser } from 'n3'; import { DataFactory, Store, Parser } from 'n3';
import { RDF } from '@/modules/namespaces'; import { RDF } from '@/modules/namespaces';
import { hashSubgraph, getNodeContextKey, collectBlankNodeHierarchy} from '@/modules/utils'; import { hashSubgraph, getNodeContextKey, collectBlankNodeHierarchy, getUniqueRootNodes} from '@/modules/utils';
const { blankNode} = DataFactory; const { blankNode} = DataFactory;
export class ReactiveRdfDataset extends RdfDataset { export class ReactiveRdfDataset extends RdfDataset {
@ -92,7 +92,16 @@ export class ReactiveRdfDataset extends RdfDataset {
}); });
} }
emitAddedRecords(records) {
this.dispatchEvent(
new CustomEvent('recordsChanged', {
detail: {records: records}
})
);
}
async parseTTLandDedup(ttlString) { async parseTTLandDedup(ttlString) {
let uniqueRecords
const parser = new Parser(); const parser = new Parser();
const tempStore = new Store(); const tempStore = new Store();
let addedQuads = []; let addedQuads = [];
@ -128,7 +137,9 @@ export class ReactiveRdfDataset extends RdfDataset {
console.warn('No root named node detected in TTL, skipping deduplication steo and adding all quads to graph.'); console.warn('No root named node detected in TTL, skipping deduplication steo and adding all quads to graph.');
let bnQuads = tempStore.getQuads(null, null, null, null) let bnQuads = tempStore.getQuads(null, null, null, null)
this.data.graph.addQuads(bnQuads); this.data.graph.addQuads(bnQuads);
return addedQuads.concat(bnQuads); let allAddedQuads = addedQuads.concat(bnQuads);
uniqueRecords = getUniqueRootNodes(allAddedQuads, this.data.graph)
return {quads: allAddedQuads, records: uniqueRecords}
} }
// Now get all unique blank-node subject values // Now get all unique blank-node subject values
const allTempQuads = tempStore.getQuads(null, null, null, null); const allTempQuads = tempStore.getQuads(null, null, null, null);
@ -136,7 +147,10 @@ export class ReactiveRdfDataset extends RdfDataset {
allTempQuads.filter(q => q.subject.termType === 'BlankNode').map(q => q.subject.value) allTempQuads.filter(q => q.subject.termType === 'BlankNode').map(q => q.subject.value)
); );
// If there are no blank-node subject values, no need to deduplicate // If there are no blank-node subject values, no need to deduplicate
if (blankSubjects.size == 0) return addedQuads; if (blankSubjects.size == 0) {
uniqueRecords = getUniqueRootNodes(addedQuads, this.data.graph)
return {quads: addedQuads, records: uniqueRecords}
}
// Initialize set of fingerprints in the root-node context // Initialize set of fingerprints in the root-node context
if (!this.data.subgraphFingerprintsByRoot.has(root_node)) { if (!this.data.subgraphFingerprintsByRoot.has(root_node)) {
this.data.subgraphFingerprintsByRoot.set(root_node, new Set()); this.data.subgraphFingerprintsByRoot.set(root_node, new Set());
@ -169,6 +183,7 @@ export class ReactiveRdfDataset extends RdfDataset {
// console.log(`Skipping duplicate subgraph for root ${root_node}, blank node ${bnodeId}`); // console.log(`Skipping duplicate subgraph for root ${root_node}, blank node ${bnodeId}`);
} }
} }
return addedQuads; uniqueRecords = getUniqueRootNodes(addedQuads, this.data.graph)
return {quads: addedQuads, records: uniqueRecords}
} }
} }

View file

@ -368,6 +368,8 @@ async function saveForm() {
) )
) { ) {
nodesToSubmit.value.push(saved_node); nodesToSubmit.value.push(saved_node);
// we also need to add/update the record to the global list
rdfDS.emitAddedRecords([saved_node.node_iri])
} }
removeForm(saved_node); removeForm(saved_node);
if (nodesToSubmit.value.length) { if (nodesToSubmit.value.length) {

View file

@ -90,6 +90,7 @@ const templates = reactive({
}) })
const hasCreatedQuads = ref(false) const hasCreatedQuads = ref(false)
const createdQuads = ref([]) const createdQuads = ref([])
const createdRecords = ref([])
const createdDistributions = new Set() const createdDistributions = new Set()
const savedNodes = inject('savedNodes'); const savedNodes = inject('savedNodes');
const nodesToSubmit = inject('nodesToSubmit'); const nodesToSubmit = inject('nodesToSubmit');
@ -163,9 +164,12 @@ async function onUploadComplete(result) {
TTLdata.pid = toIRI(TTLdata.pid, allPrefixes) TTLdata.pid = toIRI(TTLdata.pid, allPrefixes)
let newTTL = fillStringTemplate(templates.ttl, TTLdata) let newTTL = fillStringTemplate(templates.ttl, TTLdata)
let newQuads = await rdfDS.parseTTLandDedup(newTTL); let newQuads = await rdfDS.parseTTLandDedup(newTTL);
rdfDS.triggerReactivity();
// Keep track of quads that were added, so that we can delete them if form is cancelled // Keep track of quads that were added, so that we can delete them if form is cancelled
createdQuads.value = createdQuads.value.concat(newQuads) // For the same reason, we also don't yet call rdfDS.emitAddedRecords() here, as we do in useData
// after a call to rdfDS.parseTTLandDedup; if the form is cancelled we don't want these items in
// the global list. We let the form save function handle this.
createdQuads.value = createdQuads.value.concat(newQuads.quads)
createdRecords.value = createdRecords.value.concat(newQuads.records)
hasCreatedQuads.value = true; hasCreatedQuads.value = true;
// Keep track of distributions that were added // Keep track of distributions that were added
createdDistributions.add(hash) createdDistributions.add(hash)
@ -204,6 +208,8 @@ onBeforeUnmount( () => {
} }
} }
} }
// we also emit the added records
rdfDS.emitAddedRecords(createdRecords.value)
} }
}) })
</script> </script>

View file

@ -0,0 +1,392 @@
<template>
<v-card :variant="props.variant" style="width: fit-content;">
<v-card-text v-if="!props.formOpen" :class="mobile ? 'text-caption' : ''" style="display: flex; align-items: center; gap: 6px; padding: 5px">
<v-tooltip :text="toCURIE(record.subtitle, allPrefixes)" location="top left">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" color="primary">{{ getClassIcon(record.subtitle) }}</v-icon>
</template>
</v-tooltip>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn
variant="tonal"
size="x-small"
class="rounded-lg"
:disabled="props.formOpen || !canEditClass"
v-bind="props"
density="comfortable"
icon="mdi-dots-vertical">
</v-btn>
</template>
<v-list density="compact">
<v-list-item
:key="1"
:value="1"
density="compact"
@click="editInstanceItem(record)"
>
<v-list-item-title class="small-text"><v-icon :icon="'mdi-pencil'"></v-icon>&nbsp;&nbsp;Edit record</v-list-item-title>
</v-list-item>
<v-list-item
:key="2"
:value="2"
density="compact"
@click="viewRDF()"
>
<v-list-item-title class="small-text"><v-icon :icon="'mdi-file-eye-outline'"></v-icon>&nbsp;&nbsp;View RDF</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<span class="card-title">
{{ record.prefLabel ? record.prefLabel : ( record.displayLabel ? record.displayLabel : record.title) }}
</span>
<span v-if="resolveExternally">
<sup
><a
class="inline-icon-btn"
:href="toIRI(record.title, allPrefixes)"
target="_blank"
><small><v-icon>mdi-arrow-top-right-thick</v-icon></small></a
></sup
>
</span>
</v-card-text>
</v-card>
<v-dialog
v-model="ttlDialog"
:max-width="mobile ? '90%' : '60%'"
@click:outside="ttlDialog = false"
>
<v-card>
<v-card-title
>RDF record for: <em>{{ ttlDialog_name }}</em></v-card-title
>
<v-card-subtitle
><v-icon>{{ ttlDialog_icon }}</v-icon>
{{ ttlDialog_type }}</v-card-subtitle
>
<v-card-text>
<code>
<pre style="overflow-x: scroll">
{{ ttlDialog_content }}
</pre
>
</code>
</v-card-text>
<v-card-actions>
<v-btn prepend-icon="mdi-download" @click="downloadTTL()"
>Download</v-btn
>
<v-btn prepend-icon="mdi-close-box" @click="ttlDialog = false"
>Close</v-btn
>
</v-card-actions>
</v-card>
</v-dialog>
</template>
<script setup>
import {
reactive,
onBeforeMount,
inject,
ref,
watch,
provide,
computed,
} from 'vue';
import {
makeReadable,
getPrefLabel,
nameOrCURIE,
getPidQuad,
dlTTL,
toSnakeCase,
quadsToTTL,
getRecordQuads,
getRecordDisplayLabel,
hasConfigDisplayLabel,
getNodeShapePropertyWithAnnotations,
getSubjectQuad,
getDisplayName,
quadsToTripleObject,
findBlankNodeLink,
toIRI,
toCURIE,
} from '@/modules/utils';
import { RDF, SHACL } from '@/modules/namespaces';
import MoreOrLessRecordsViewer from '@/components/MoreOrLessRecordsViewer.vue';
import SpecialButton from '@/components/SpecialButton.vue'
import { useCompConfig } from '@/composables/useCompConfig';
import { useDisplay } from 'vuetify'
import BackLinkViewer from './BackLinkViewer.vue';
const { mobile } = useDisplay()
// Define component properties
const props = defineProps({
classIRI: String,
quad: Object,
variant: String,
formOpen: Boolean,
});
const editInstanceItem = inject('editInstanceItem');
const configVarsMain = inject('configVarsMain');
const allPrefixes = inject('allPrefixes');
const fetchFromService = inject('fetchFromService');
const getClassIcon = inject('getClassIcon');
const rdfDS = inject('rdfDS');
const shapesDS = inject('shapesDS');
const lastSavedNode = inject('lastSavedNode');
const record = reactive({});
const showBlankNodes = ref(false);
const shape_obj = shapesDS.data.nodeShapes[props.classIRI];
const resolveExternally = ref(false);
const linkCopied = ref(false)
const showCopyLink = ref(false)
const propertyShapes = {};
for (var p of shape_obj.properties) {
propertyShapes[p[SHACL.path.value]] = p;
}
const {componentName, componentConfig} = useCompConfig(configVarsMain);
const defaultStep = componentConfig?.recordNumberStepSize ? componentConfig.recordNumberStepSize : 5;
let textTruncateWidth;
if (componentConfig?.textTruncateWidth === false) {
textTruncateWidth = null
} else if (!componentConfig?.textTruncateWidth) {
textTruncateWidth = '85%'
} else {
textTruncateWidth = componentConfig.textTruncateWidth
}
const textWrapping = textTruncateWidth ? 'nowrap' : 'wrap'
const showCounts = reactive(
{
'Literal': {},
'NamedNode': {},
'BlankNode': {},
'BlankNodeSpecial': {},
}
);
const ttlDialog = ref(false);
const ttlDialog_icon = ref('');
const ttlDialog_name = ref('');
const ttlDialog_type = ref('');
const ttlDialog_content = ref('');
const fetchingRecords = ref(false);
const canEditClass = ref(false);
const showSpecialButtons = ref(false);
const specialButtons = reactive({});
const showBackLinks = ref(false);
const firstUpdateDone = ref(false);
const hideBackLinksConfig = componentConfig?.hideBackLinks;
const hideBackLinks = ref(true);
if (hideBackLinksConfig === false || Array.isArray(hideBackLinksConfig) &&
!hideBackLinksConfig.includes(toCURIE(props.classIRI, allPrefixes))) {
hideBackLinks.value = false
}
const emit = defineEmits(['namedNodeSelected']);
function selectNamedNode(recordClass, recordPID) {
emit('namedNodeSelected', { recordClass, recordPID });
}
provide('selectNamedNode', selectNamedNode);
onBeforeMount(async () => {
if (configVarsMain.allowCopyRecordUrls === true ||
( Array.isArray(configVarsMain.allowCopyRecordUrls) &&
configVarsMain.allowCopyRecordUrls.indexOf(props.classIRI) >= 0 )
) {
showCopyLink.value = true;
}
canEditClass.value = configVarsMain.noEditClasses.indexOf(toCURIE(props.classIRI, allPrefixes)) < 0 ? true : false
fetchingRecords.value = true;
updateRecord(true);
fetchingRecords.value = false;
firstUpdateDone.value = true;
let recordPIDprefix = toCURIE(props.quad.subject.value, allPrefixes, 'parts').prefix;
if (configVarsMain['idResolvesExternally'].indexOf(recordPIDprefix) >= 0) {
resolveExternally.value = true;
}
});
const specialBlankNodes = computed( () => {
const triples = record.triples?.['BlankNode'] ?? {};
const result = {};
for (const [key, v] of Object.entries(triples)) {
if (!v.configDisplayLabel || !Array.isArray(v.values)) continue
const merged = v.values.map((value, i) => ({
value,
displayLabel: v.displayLabels?.[i] ?? '',
keyPropertyRole: v.keyPropertyRoles?.[i] ?? null,
}))
const sorted = merged.sort((a, b) => {
// display labels starting with 'http' are deprioritized
const aIsHttp = a.displayLabel.trim().toLowerCase().startsWith('http')
const bIsHttp = b.displayLabel.trim().toLowerCase().startsWith('http')
if (aIsHttp && !bIsHttp) return 1
if (!aIsHttp && bIsHttp) return -1
// within each group, sort alphabetically
return a.displayLabel.localeCompare(b.displayLabel, undefined, { sensitivity: 'base' })
})
result[key] = {
...v,
items: sorted,
}
}
return result
})
async function viewRDF() {
ttlDialog.value = false;
ttlDialog_icon.value = getClassIcon(props.classIRI);
ttlDialog_name.value = record.prefLabel ? record.prefLabel : record.title;
ttlDialog_type.value = toCURIE(record.subtitle, allPrefixes);
var rQs = getRecordQuads(record.value, rdfDS.data.graph, true)
var tmpStr = await quadsToTTL(rQs, allPrefixes);
ttlDialog_content.value = tmpStr.replace(/^\s+/g, '');
ttlDialog_content.value = '\n' + ttlDialog_content.value;
ttlDialog.value = true;
}
function downloadTTL() {
dlTTL(ttlDialog_content.value, toSnakeCase(ttlDialog_name.value) + '.ttl');
}
function showHideBlankNodes() {
showBlankNodes.value = !showBlankNodes.value;
}
function updateRecord(fetchData, from) {
record.title = props.quad.subject.value;
record.quad = props.quad;
record.value = props.quad.subject.value;
record.subtitle = props.quad.object.value;
record.relatedQuads = rdfDS.getSubjectTriples(props.quad.subject);
record.prefLabel = getPrefLabel(props.quad.subject, rdfDS, allPrefixes);
record.triples = {
Literal: {},
BlankNode: {},
NamedNode: {},
};
record.displayLabel = getRecordDisplayLabel(record.quad.subject, rdfDS, allPrefixes, configVarsMain)
}
async function addRecordProperty(quad, fetchData) {
var termType = quad.object.termType;
if (
termType === 'NamedNode' &&
quad.predicate.value != RDF.type.value &&
fetchData
) {
const results = await fetchFromService(
'get-record',
quad.object.value,
allPrefixes
);
}
if (!record.triples[termType].hasOwnProperty(quad.predicate.value)) {
record.triples[termType][quad.predicate.value] = {
values: [],
displayLabels: [],
prefLabels: [],
keyPropertyRoles: [],
relatedTriples: [],
};
}
let kpr = null
if (termType === 'BlankNode') {
let ps = propertyShapes[quad.predicate.value]
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:")
console.log(bnQuad.object.value)
const results = await fetchFromService(
'get-record',
bnQuad.object.value,
allPrefixes
);
console.log(results)
}
if (keyPropertyRole && bnQuad.predicate.value === keyPropertyRole) {
let iri = null
let subjQ = getSubjectQuad(bnQuad.object, rdfDS.data.graph)
if (subjQ) {
iri = subjQ?.object?.value
} else {
iri = keyPropertyShape[SHACL.class.value];
}
kpr = {
classIRI: iri,
recordPID: bnQuad.object.value
}
}
}
}
if (kpr) {
record.triples[termType][quad.predicate.value].keyPropertyRoles.push(kpr);
} else {
record.triples[termType][quad.predicate.value].keyPropertyRoles.push(null);
}
// selectNamedNode(currentClassIRI, currentRecordPID)
record.triples[termType][quad.predicate.value].values.push(quad.object);
}
function copyRecordLink() {
var nodeShapeCurie = toCURIE(props.classIRI, allPrefixes);
var pidCurie = toCURIE(props.quad.subject.value, allPrefixes);
var nsQPvar = encodeURIComponent('sh:NodeShape')
var nsQP = encodeURIComponent(nodeShapeCurie)
var pidQP = encodeURIComponent(pidCurie)
var queryParams = `?${nsQPvar}=${nsQP}&pid=${pidQP}`;
var urlText = window.location.origin + window.location.pathname + queryParams
copyTextToClipboard(urlText)
}
async function copyTextToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
linkCopied.value = true
setTimeout(() => {
linkCopied.value = false;
}, 1000);
} catch (err) {
console.error('Clipboard copy failed:', err);
}
}
</script>
<style scoped>
.line-item {
display: block;
width: 100%;
max-width: 100%;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.card-title {
display: inline-block;
text-wrap: wrap;
max-width: 90%;
line-height: 1.2em;
vertical-align: middle;
}
.mobile-scaled {
transform: scale(0.75);
transform-origin: top left;
width: 120%;
}
.small-text {
font-size: 12px;
}
</style>

View file

@ -135,11 +135,10 @@
<ShaclVueRecords <ShaclVueRecords
:selectedIRI="selectedIRI" :selectedIRI="selectedIRI"
:classRecordsLoading="classRecordsLoading" :classRecordsLoading="classRecordsLoading"
:instanceItemsComp="instanceItemsComp"
:mobile="mobile" :mobile="mobile"
:showScrollTopBtn="showScrollTopBtn" :showScrollTopBtn="showScrollTopBtn"
:matchedInstanceItemsComp="matchedInstanceItemsComp" :filteredRecords="(includeSubClasses ? filteredRecordItemsForClassWithSubclassItems : filteredRecordItemsByClass[selectedIRI]) || []"
:filteredInstanceItemsComp="filteredInstanceItemsComp" :fetchedItemCount="fetchedItemCount"
:showFetchingPageLoader="showFetchingPageLoader" :showFetchingPageLoader="showFetchingPageLoader"
v-model:searchText="searchText" v-model:searchText="searchText"
v-model:textMatchType="textMatchType" v-model:textMatchType="textMatchType"
@ -337,7 +336,25 @@ const {
// Classes from OWL // Classes from OWL
const { classDS, getClassData, allSubClasses, processSubClasses} = useClasses(config); const { classDS, getClassData, allSubClasses, processSubClasses} = useClasses(config);
// Shapes from SHACL // Shapes from SHACL
const { shapesDS, getSHACLschema, updateShapesFromDefault, updateShapes, updatePropertyGroups} = useShapes(config); const {
shapesDS,
getSHACLschema,
updateShapesFromDefault,
updateShapes,
updatePropertyGroups,
idFilteredNodeShapeNames,
noEditClassList,
filteredNodeShapeNames,
priorityFilteredNodeShapeNames,
orderedNodeShapeNames,
allClassItems,
getIdFilteredNodeShapeNames,
getNoEditClassList,
getFilteredNodeShapeNames,
getPriorityFilteredNodeShapeNames,
getOrderedNodeShapeNames,
getAllClassItems,
} = useShapes(config);
// Graph data // Graph data
const { const {
fetchedPages, fetchedPages,
@ -358,14 +375,9 @@ const {
classRecordsLoading, classRecordsLoading,
currentProgress, currentProgress,
fetchedItemCount, fetchedItemCount,
filteredInstanceItemsComp,
getInstanceItems,
headingHover, headingHover,
includeSubClasses, includeSubClasses,
instanceItemsComp,
isFetchingPage, isFetchingPage,
matchedInstanceItemsComp,
newTypeSelected,
onScrollEnd, onScrollEnd,
onUserTyping, onUserTyping,
orderTopDown, orderTopDown,
@ -375,6 +387,10 @@ const {
showScrollTopBtn, showScrollTopBtn,
textMatchType, textMatchType,
totalItemCount, totalItemCount,
recordItemsByClass,
filteredRecordItemsAll,
filteredRecordItemsByClass,
filteredRecordItemsForClassWithSubclassItems,
} = useRecords( } = useRecords(
allPrefixes, allPrefixes,
allSubClasses, allSubClasses,
@ -408,7 +424,6 @@ const {
onEditInstanceItem: afterEditInstanceItem, onEditInstanceItem: afterEditInstanceItem,
onAddForm: scrollToTop, onAddForm: scrollToTop,
onRemoveForm: afterFormsClosed, // will run when last form is closed onRemoveForm: afterFormsClosed, // will run when last form is closed
onRemoveFormSaved: getInstanceItems, // will run when last form is closed via save
} }
}); });
// App navigation // App navigation
@ -521,6 +536,13 @@ watch(
processSearchableFields(); processSearchableFields();
// Set component states from URL query parameters // Set component states from URL query parameters
setViewFromQuery(); setViewFromQuery();
// Get all class-related data
idFilteredNodeShapeNames.value = getIdFilteredNodeShapeNames(configVarsMain, ID_IRI);
noEditClassList.value = getNoEditClassList(configVarsMain, allPrefixes);
filteredNodeShapeNames.value = getFilteredNodeShapeNames(configVarsMain, allPrefixes);
priorityFilteredNodeShapeNames.value = getPriorityFilteredNodeShapeNames(priorityClassList);
orderedNodeShapeNames.value = getOrderedNodeShapeNames(configVarsMain, allPrefixes);
allClassItems.value = getAllClassItems(configVarsMain, allPrefixes, getClassIcon);
page_ready.value = true; page_ready.value = true;
} }
}, },
@ -538,111 +560,6 @@ onBeforeUnmount(() => {
// -------------- // // -------------- //
// Computed props // // Computed props //
// -------------- // // -------------- //
// These are all arays of classes that are eventually represented in the
// main class-selection pane in this component
const idFilteredNodeShapeNames = computed(() => {
if (configVarsMain.showShapesWoId === true) {
return shapesDS.data.nodeShapeNamesArray;
}
var shapeNames = [];
for (var n of shapesDS.data.nodeShapeNamesArray) {
if (
findObjectByKey(
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[n]]
.properties,
SHACL.path.value,
ID_IRI.value
)
) {
shapeNames.push(n);
}
}
return shapeNames;
});
const filteredNodeShapeNames = computed(() => {
var names = idFilteredNodeShapeNames.value;
// If all relevant config arrays are empty, show all classes
if (
configVarsMain.showClasses?.length == 0 &&
configVarsMain.showClassesWithPrefix?.length == 0 &&
configVarsMain.hideClasses?.length == 0 &&
configVarsMain.hideClassesWithPrefix?.length == 0 &&
configVarsMain.noEditClasses?.length == 0
) {
return names;
}
var shapeNames = [];
for (var n of names) {
// First get IRI and prefix
var n_iri = shapesDS.data.nodeShapeNames[n]
if (includeClass(n_iri, configVarsMain, allPrefixes) && configVarsMain.noEditClasses.indexOf(toCURIE(n_iri, allPrefixes)) < 0) {
shapeNames.push(n);
}
}
return shapeNames;
});
const priorityFilteredNodeShapeNames = computed(() => {
var names = filteredNodeShapeNames.value;
var shapeNames = [];
for (var n of names) {
var n_iri = shapesDS.data.nodeShapeNames[n]
if (!priorityClassList.value.includes(n_iri)) {
shapeNames.push(n);
}
}
return shapeNames;
})
const orderedNodeShapeNames = computed(() => {
return priorityFilteredNodeShapeNames.value.sort((a, b) =>
getDisplayName(
shapesDS.data.nodeShapeNames[a],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[a]]
).toLowerCase()
.localeCompare(
getDisplayName(
shapesDS.data.nodeShapeNames[b],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[b]]
).toLowerCase()
)
);
})
const noEditClassList = computed(() => {
if (configVarsMain.noEditClasses?.length == 0) return []
var names = idFilteredNodeShapeNames.value;
var shapeNames = [];
for (var n of names) {
// First get IRI and prefix
var n_iri = shapesDS.data.nodeShapeNames[n]
if (includeClass(n_iri, configVarsMain, allPrefixes) &&
configVarsMain.noEditClasses?.indexOf(toCURIE(n_iri, allPrefixes)) >= 0) {
shapeNames.push(n);
}
}
return shapeNames.sort((a, b) =>
getDisplayName(
shapesDS.data.nodeShapeNames[a],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[a]]
).toLowerCase()
.localeCompare(
getDisplayName(
shapesDS.data.nodeShapeNames[b],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[b]]
).toLowerCase()
)
);
})
// --------- // // --------- //
// Functions // // Functions //
@ -688,7 +605,6 @@ async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
totalItemCount.value = 0 totalItemCount.value = 0
isFetchingPage.value = false; isFetchingPage.value = false;
showScrollTopBtn.value = false; showScrollTopBtn.value = false;
newTypeSelected.value = true;
var tempSearchText = searchText.value; var tempSearchText = searchText.value;
var tempIRI = selectedIRI.value; var tempIRI = selectedIRI.value;
searchText.value = ''; searchText.value = '';
@ -708,10 +624,8 @@ async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
console.error(result.error); console.error(result.error);
classRecordsLoading.value = false; classRecordsLoading.value = false;
} }
// If any of the results were successful, don't set classRecordsLoading to false // If any of the results were successful, do nothing
// because it will be set during the watch event for instanceItemsComp
if (result.status.length && result.status.indexOf('success') >= 0) { if (result.status.length && result.status.indexOf('success') >= 0) {
// do nothing
} else { } else {
classRecordsLoading.value = false; classRecordsLoading.value = false;
} }
@ -723,7 +637,7 @@ async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
totalItemCount.value = totalItems totalItemCount.value = totalItems
} }
} }
getInstanceItems(); classRecordsLoading.value = false
scrollToTop(); scrollToTop();
if (fromUser) { if (fromUser) {
updateURL(IRI, false, null, allPrefixes); updateURL(IRI, false, null, allPrefixes);

View file

@ -3,8 +3,8 @@
<v-skeleton-loader type="list-item-avatar"></v-skeleton-loader> <v-skeleton-loader type="list-item-avatar"></v-skeleton-loader>
</span> </span>
<span v-else> <span v-else>
<div v-if="props.instanceItemsComp.length"> <div v-if="props.fetchedItemCount">
<v-row v-if="props.instanceItemsComp.length"> <v-row v-if="props.fetchedItemCount">
<v-col :cols="props.mobile ? 12 : 8"> <v-col :cols="props.mobile ? 12 : 8">
<v-text-field <v-text-field
v-model="searchText" v-model="searchText"
@ -64,11 +64,7 @@
</template> </template>
</v-tooltip> </v-tooltip>
<DynamicScroller <DynamicScroller
:items=" :items="props.filteredRecords"
textMatchType == 'exact' ?
props.matchedInstanceItemsComp :
props.filteredInstanceItemsComp
"
page-mode page-mode
:min-item-size="50" :min-item-size="50"
key-field="title" key-field="title"
@ -123,13 +119,12 @@ import {
// ----- // // ----- //
const props = defineProps({ const props = defineProps({
selectedIRI: String, selectedIRI: String,
instanceItemsComp: Array,
classRecordsLoading: Boolean, classRecordsLoading: Boolean,
mobile: Boolean, mobile: Boolean,
showScrollTopBtn: Boolean, showScrollTopBtn: Boolean,
scrollToTop: Function, scrollToTop: Function,
matchedInstanceItemsComp: Array, filteredRecords: Array,
filteredInstanceItemsComp: Array, fetchedItemCount: Number,
showFetchingPageLoader: Boolean, showFetchingPageLoader: Boolean,
}); });

View file

@ -0,0 +1,125 @@
<template>
<span v-if="props.classRecordsLoading">
<v-skeleton-loader type="list-item-avatar"></v-skeleton-loader>
</span>
<span v-else>
<div v-if="props.items.length">
<DynamicScroller
:items="props.items"
:min-item-size="100"
key-field="value"
class="virtual-scroller"
@scroll-end="scrollEnd()"
>
<template v-slot="{ item, index, active, }">
<DynamicScrollerItem
:item="item"
:index="index"
:active="active"
class="scroller-item"
:ref="itemRefs[index]"
>
<template #default>
<NodeShapeViewerMini
:classIRI="props.classIRI"
:quad="item.props.quad"
:key="props.selectedIRI + '-' + item.title"
variant="outlined"
@namedNodeSelected="onNamedNodeSelected"
/>
</template>
</DynamicScrollerItem>
</template>
<template #after>
<div class="after-loader" :style="'color: ' + configVarsMain.appTheme.link_color + ';'">
<v-progress-circular v-show="props.showFetchingPageLoader" indeterminate :size="20" :width="4"></v-progress-circular>
</div>
</template>
</DynamicScroller>
</div>
<div v-else style="margin-top: 1em; margin-left: 1em;">
<em>No items</em>
</div>
</span>
</template>
<script setup>
import { computed, inject, ref, onBeforeMount} from 'vue';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import {
DynamicScroller,
DynamicScrollerItem,
} from 'vue-virtual-scroller';
import NodeShapeViewerMini from './NodeShapeViewerMini.vue';
import { useDisplay } from 'vuetify'
const { mdAndUp, lgAndUp } = useDisplay()
// ----- //
// PROPS //
// ----- //
const props = defineProps({
classIRI: String,
items: Array,
classRecordsLoading: Boolean,
showFetchingPageLoader: Boolean,
});
const emit = defineEmits([
'handle-internal-navigation',
'scroll-end',
])
const configVarsMain = inject('configVarsMain')
const itemRefs = ref([]);
function onNamedNodeSelected(payload) {
emit('handle-internal-navigation', payload)
}
function scrollEnd() {
emit('scroll-end')
}
</script>
<style>
.row-sheet {
max-height: 30vh;
overflow: hidden;
}
.left-col {
height: 100%;
}
.right-col {
height: 100%;
}
.virtual-scroller {
max-height: 25vh;
overflow-y: auto;
}
.scroller-item {
padding-bottom: 3px;
}
</style>
<style scoped>
.after-loader {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.mobile-scaled {
transform: scale(0.75);
transform-origin: top left;
width: 120%;
}
</style>

View file

@ -0,0 +1,841 @@
<template>
<AppHeader v-if="configReady" :logo="configVarsMain.appTheme.logo" @tokenDialogOpened="onTokenDialogOpened"/>
<v-main>
<v-container fluid>
<span v-if="page_ready">
<v-card>
<v-layout>
<!-- Button to open/close class selection pane -->
<v-btn
v-if="mobile"
:icon="drawer ? 'mdi-chevron-left' : 'mdi-chevron-right'"
size="40"
class="drawer-fab"
@click="drawer = !drawer"
theme="dark"
:color="configVarsMain.appTheme.panel_color"
></v-btn>
<!-- Class selection pane -->
<v-navigation-drawer
theme="dark"
:color="configVarsMain.appTheme.panel_color"
v-model="drawer"
style="overflow-y: auto"
:permanent="!mobile"
:temporary="mobile"
app
>
<v-list
nav
v-model:selected="classSelection"
select-strategy="leaf"
>
<v-list-item>
<template #prepend>
<v-list-item-action start>
<v-checkbox-btn
:model-value="allSelected"
:indeterminate="isIndeterminate"
@update:model-value="toggleAll"
/>
</v-list-item-action>
</template>
<v-list-item-title>
<h4>All Data Types</h4>
</v-list-item-title>
</v-list-item>
<v-list-item
v-for="item in allClassItems"
:key="item.value"
:title="item.title"
:value="item.value"
>
<template v-slot:prepend="{ isSelected, select }">
<v-list-item-action start>
<v-checkbox-btn :model-value="isSelected" @update:model-value="select"></v-checkbox-btn>
</v-list-item-action>
</template>
<template v-slot:append="{ }">
<v-tooltip :text="item.props.description" location="end" max-width="500px">
<template v-slot:activator="{ props }">
<v-icon v-bind="props">{{ item.props.icon }}</v-icon>
</template>
</v-tooltip>
</template>
</v-list-item>
</v-list>
</v-navigation-drawer>
<!-- The main view containing records and forms -->
<v-main
ref="mainContent"
style="height: 90vh; overflow-y: auto"
>
<!-- Everything is inside one container and one row -->
<v-container fluid>
<v-row v-if="fetchingFirstPages" justify="center" class="mt-4">
<div class="text-center">
<v-progress-circular indeterminate :size="90" :width="3" :color="configVarsMain.appTheme.link_color">
<template v-slot:default><em> Fetching<br>data... </em></template>
</v-progress-circular>
</div>
</v-row>
<v-row v-else>
<!-- Column for records -->
<v-col
v-show="formOpen ? false : true"
class="transition-all ml-1"
:class="formOpen ? 'opacity-column' : ''"
>
<h1 style="margin-bottom: 0.5em;">All of the Things</h1>
<v-row>
<v-col :cols="props.mobile ? 12 : 8">
<v-text-field
v-model="searchInput"
density="compact"
variant="outlined"
:label="`Enter at least ${configVarsMain.serviceConstrainedSearch.min_characters} characters to search all records`"
hide-details="auto"
style="margin-bottom: 1em;"
:disabled="openForms.length > 0"
:class="props.mobile ? 'mobile-scaled' : '' "
>
<template v-slot:append-inner>
<v-icon
v-if="searchInput"
class="mr-2"
@click.stop="clearField()"
@mousedown.stop.prevent
>
mdi-close-circle
</v-icon>
</template>
</v-text-field>
</v-col>
<v-col v-if="!props.mobile"></v-col>
</v-row>
<v-tooltip text="Scroll to top" location="top end">
<template v-slot:activator="{ props: activatorProps }">
<v-fab
v-if="props.showScrollTopBtn && openForms.length == 0"
@click="scrollToTop"
icon="mdi-arrow-up-bold"
:app="true"
style="bottom: 2em;"
v-bind="activatorProps"
></v-fab>
</template>
</v-tooltip>
<!-- All Class records -->
<span v-for="cl in allClassItems" :key="cl.value">
<v-sheet
v-show="classSelection.includes(cl.value) && cl.props.totalItemCount && itemsByClass[cl.value]?.length"
class="row-sheet border rounded pa-4 mb-4"
>
<v-row class="h-25" style="overflow-y: scroll;">
<v-col cols="6" class="left-col">
<h3 style="margin-bottom: 1em;">
<v-icon>{{ cl.props.icon }}</v-icon> {{ cl.title }}
<span v-if="showWizardGroup(
configVarsMain,
'_class',
cl.value,
allPrefixes,
shapesDS
)">
<WizardGroup :context="'_class'" :classUri="cl.value"></WizardGroup>
</span>
</h3>
<p>{{ cl.props.description }}</p>
</v-col>
<v-col class="right-col">
<ShaclVueRecordsMini
:classIRI="cl.value"
:items="itemsByClass[cl.value] || []"
class="right-col-div"
@scroll-end="onScrollEndOfClass(cl.value)"
/>
</v-col>
</v-row>
</v-sheet>
</span>
</v-col>
<!-- Column for Form(s) -->
<v-col v-if="formOpen">
<v-expansion-panels
variant="accordion"
v-model="currentOpenForm"
class="custompanels"
>
<v-expansion-panel
v-for="(f, i) in openForms"
:key="
f.shapeIRI +
'-' +
f.nodeIDX +
'-expansionpanel'
"
:value="'panel' + (i + 1).toString()"
:disabled="f.disabled"
>
<v-expansion-panel-title>
<h2>
<em>
{{ f.formType === 'new' ? 'Adding' : 'Editing' }}:
{{
getDisplayName(
f.shapeIRI,
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[f.shapeIRI]
)
}}
</em>
</h2>
</v-expansion-panel-title>
<v-expansion-panel-text density="compact" eager>
<div v-show="currentOpenForm === ('panel' + (i + 1))">
<FormEditor
:key="
f.shapeIRI +
'-' +
f.nodeIDX +
'-form-' +
f.formType
"
:shape_iri="f.shapeIRI"
:node_idx="f.nodeIDX"
></FormEditor>
</div>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</v-col>
</v-row>
</v-container>
</v-main>
<!-- Button to open/close submission drawer -->
<span v-if="configVarsMain.useService">
<v-navigation-drawer
theme="dark"
:color="configVarsMain.appTheme.panel_color"
v-model="submissionDrawer"
style="overflow-y: auto"
:temporary="true"
location="right"
:width="800"
app
>
<SubmitComp v-if="submissionDrawer" v-model:selectedNodesToSubmit="selectedNodesToSubmit" :openCloseFn="submitFn"></SubmitComp>
</v-navigation-drawer>
</span>
</v-layout>
</v-card>
</span>
<span v-else>
<v-skeleton-loader type="article"></v-skeleton-loader>
</span>
</v-container>
</v-main>
<AppFooter />
</template>
<!------------>
<!-- SCRIPT -->
<!------------>
<script setup>
// ------- //
// IMPORTS //
// ------- //
import {
computed,
nextTick,
onBeforeUnmount,
onMounted,
provide,
reactive,
ref,
toRaw,
watch,
} from 'vue';
import { useConfig } from '@/composables/configuration';
import {
findObjectByKey,
getDisplayName,
includeClass,
toCURIE,
toIRI,
} from '@/modules/utils';
import editorMatchers from '@/modules/editors';
// Leave the viewerMatchers import here to load viewers, even if unused in this component
import viewerMatchers from '@/modules/viewers';
import defaultEditor from '@/components/UnknownEditor.vue';
import { useData } from '@/composables/useData';
import { useClasses } from '@/composables/useClasses';
import { useShapes } from '@/composables/useShapes';
import { useForm } from '@/composables/useForm';
import { useToken } from '@/composables/tokens';
import { RDFS, SHACL } from '@/modules/namespaces';
import { useDisplay } from 'vuetify'
import ShaclVueRecordsMini from '@/components/ShaclVueRecordsMini.vue';
import { useRecords } from '@/composables/useRecords';
import { useNavigation } from '@/composables/useNavigation';
import { useSubmit } from '@/composables/useSubmit';
import { showWizardGroup } from '@/composables/useWizard'
// ----- //
// PROPS //
// ----- //
// We only receive the config file url, everything depends on it
const props = defineProps({
configUrl: String,
});
// ---------------- //
// MAIN DATA FOR UI //
// ---------------- //
const { mobile } = useDisplay()
const page_ready = ref(false);
const internalHistory = ref([]);
const firstNavigationDone = ref(false);
const mainContent = ref(null);
var selectedShape = ref(null);
var selectedIRI = ref(null);
const canSubmit = ref(true);
var selectedItem = ref(null);
const drawer = mobile.value ? ref(false) : ref(true);
const canEditClass = ref(true)
const openForms = reactive([]);
const allItems = reactive({})
const fetchingFirstPages = ref(false);
// ------------------- //
// RUN ALL COMPOSABLES //
// ------------------- //
// Note: order is important for config and data|classes|chapes|forms
// Token handling
const { token, setToken, clearToken } = useToken();
// Configuration
const {
allPrefixes,
config,
configError,
configReady,
configVarsMain,
frontPageHTML,
getClassIcon,
ID_IRI,
mergePrefixes,
priorityClassList,
processPriorityClasses,
processPropertyGroups,
processSearchableFields,
processShapeUpdates,
processUpfrontServiceRequests,
searchableFields,
} = useConfig(props.configUrl);
// Classes from OWL
const { classDS, getClassData, allSubClasses, processSubClasses} = useClasses(config);
// Shapes from SHACL
const {
shapesDS,
getSHACLschema,
updateShapesFromDefault,
updateShapes,
updatePropertyGroups,
idFilteredNodeShapeNames,
noEditClassList,
filteredNodeShapeNames,
priorityFilteredNodeShapeNames,
orderedNodeShapeNames,
allClassItems,
getIdFilteredNodeShapeNames,
getNoEditClassList,
getFilteredNodeShapeNames,
getPriorityFilteredNodeShapeNames,
getOrderedNodeShapeNames,
getAllClassItems,
} = useShapes(config);
// Graph data
const {
fetchedPages,
fetchFromService,
firstPageFetched,
getRdfData,
getTotalItems,
hasUnfetchedPages,
http401response,
nodesToSubmit,
rdfDS,
savedNodes,
submitRdfData,
submittedNodes,
} = useData(config);
// Record list
const {
classRecordsLoading,
currentProgress,
fetchedItemCount,
fetchNextPage,
headingHover,
includedClasses,
includeSubClasses,
isFetchingPage,
onScrollEnd,
onUserTyping,
orderTopDown,
searchText,
showFetchingPageLoader,
showProgress,
showScrollTopBtn,
textMatchType,
totalItemCount,
recordItemsByClass,
filteredRecordItemsAll,
} = useRecords(
allPrefixes,
allSubClasses,
config,
configVarsMain,
fetchFromService,
firstPageFetched,
hasUnfetchedPages,
openForms,
rdfDS,
searchableFields,
selectedIRI,
);
// Form functionality
const {
addForm,
addInstanceItem,
currentOpenForm,
editInstanceItem,
editMode,
formData,
formOpen,
lastSavedNode,
removeForm,
} = useForm({
openForms,
rdfDS,
allPrefixes,
callbacks: {
onAddInstanceItem: afterAddInstanceItem,
onEditInstanceItem: afterEditInstanceItem,
onAddForm: scrollToTop,
onRemoveForm: afterFormsClosed, // will run when last form is closed
}
});
// App navigation
const {
goBack,
handleInternalNavigation,
setViewFromQuery,
updateURL,
} = useNavigation(
addInstanceItem,
allPrefixes,
configVarsMain,
editInstanceItem,
internalHistory,
rdfDS,
searchText,
selectedItem,
selectType,
setToken,
shapesDS,
textMatchType,
)
// Form submission
const {
selectedNodesToSubmit,
submissionDrawer,
submitFn,
submitWarning,
tokenWarning,
} = useSubmit(nodesToSubmit)
// ------------------------------------------------- //
// PROVIDE DATA TO COMPONENTS LOWER IN THE HIERARCHY //
// ------------------------------------------------- //
provide('allPrefixes', allPrefixes);
provide('editMode', editMode);
provide('formOpen', formOpen);
provide('editInstanceItem', editInstanceItem);
provide('addForm', addForm);
provide('openForms', openForms);
provide('removeForm', removeForm);
provide('lastSavedNode', lastSavedNode);
provide('canSubmit', canSubmit);
provide('formData', formData);
provide('config', config);
provide('configError', configError);
provide('configVarsMain', configVarsMain);
provide('ID_IRI', ID_IRI);
provide('rdfDS', rdfDS);
provide('shapesDS', shapesDS);
provide('classDS', classDS);
provide('allSubClasses', allSubClasses);
provide('fetchFromService', fetchFromService);
provide('hasUnfetchedPages', hasUnfetchedPages);
provide('getTotalItems', getTotalItems);
provide('firstPageFetched', firstPageFetched);
provide('http401response', http401response)
provide('submitRdfData', submitRdfData);
provide('savedNodes', savedNodes);
provide('submittedNodes', submittedNodes);
provide('nodesToSubmit', nodesToSubmit);
provide('searchableFields', searchableFields);
provide('editorMatchers', editorMatchers);
provide('defaultEditor', defaultEditor);
provide('getClassIcon', getClassIcon);
provide('submitFn', submitFn);
provide('tokenWarning', tokenWarning);
provide('submitWarning', submitWarning);
// --------------------- //
// Lifecycle/Vue methods //
// --------------------- //
// Once config is loaded and processed (a.k.a. ready) we load all shapes and data
watch(
configReady,
async (newValue) => {
if (newValue) {
formData.ID_IRI = ID_IRI.value;
await getRdfData();
await getClassData();
await getSHACLschema();
}
},
{ immediate: true }
);
// Some config processing can only be done once all shapes and data have been loaded (which we know by
// watching "shapes prefixes loaded" as a proxy). This is mainly because we require all prefixes.
// - merge all prefixes
// - fetch data from service (if necessary)
// - set component states from URL query parameters (if necessary)
watch(
() => shapesDS.data.prefixesLoaded,
async (newValue) => {
if (newValue) {
// Get all prefixes and derive context from it
// Prefixes are necessary for all the following steps
mergePrefixes([shapesDS.data.prefixes, rdfDS.data.prefixes, classDS.data.prefixes])
// Get list of priority classes from config
processPriorityClasses();
// Fetch data if configured via 'service_fetch_before'
processUpfrontServiceRequests(fetchFromService)
// Process configured shape updates
processShapeUpdates(updateShapesFromDefault, updateShapes)
// Add any configured property groups to shapes dataset
processPropertyGroups(updatePropertyGroups)
// Prepare allSubClasses object with class URIs as keys, and their respective subclass URIs as arrays
// This is required for the InstancesSelectEditor
processSubClasses();
// Now transform/derive the searchable fields for "filter records by"
processSearchableFields();
// Set component states from URL query parameters
setViewFromQuery();
// Get all class-related data
idFilteredNodeShapeNames.value = getIdFilteredNodeShapeNames(configVarsMain, ID_IRI);
filteredNodeShapeNames.value = getFilteredNodeShapeNames(configVarsMain, allPrefixes);
priorityFilteredNodeShapeNames.value = getPriorityFilteredNodeShapeNames(priorityClassList);
orderedNodeShapeNames.value = getOrderedNodeShapeNames(configVarsMain, allPrefixes);
allClassItems.value = getAllClassItems(configVarsMain, allPrefixes, getClassIcon);
page_ready.value = true
// then fetch first page per class
fetchingFirstPages.value = true;
await getFirstPages();
fetchingFirstPages.value = false;
// Starter components needs 'selectType' to only run once at startup
let mainIRI = 'https://concepts.datalad.org/s/things/v2/Thing';
selectType(mainIRI, true, false, true)
}
},
{ immediate: true }
);
// Warn if there are any pending records to submit when the user closes the tab/window
onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload);
});
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload);
});
// -------------- //
// Computed props //
// -------------- //
const itemsByClass = computed(() => {
const map = {};
for (const item of filteredRecordItemsAll.value) {
const cl = item.props.subtitle;
if (!map[cl]) map[cl] = [];
map[cl].push(item);
}
return map;
});
const visibleClasses = computed(() =>
allClassItems.value.filter(cl =>
classSelection.includes(cl.value) &&
itemsByClass.value[cl.value]?.length
)
);
const searchInput = ref('');
let debounceTypingTimer = null;
watch(searchInput, (val) => {
clearTimeout(debounceTypingTimer);
debounceTypingTimer = setTimeout(() => {
searchText.value = val;
}, 200); // or 300ms
});
const allValues = computed(() => allClassItems.value.map(i => i.value))
const allSelected = computed(() =>
allValues.value.length > 0 &&
allValues.value.every(v => classSelection.value.includes(v))
)
const isIndeterminate = computed(() =>
classSelection.value.length > 0 &&
classSelection.value.length < allValues.value.length
)
const classSelection = ref([])
watch(
allClassItems,
(items) => {
if (items?.length) {
classSelection.value = items.map(i => i.value);
}
},
{ immediate: true }
)
function toggleAll(val) {
classSelection.value = val ? [...allValues.value] : []
}
watch(
classSelection,
(items) => {
includedClasses.value = classSelection.value;
},
{ immediate: true }
)
// --------- //
// Functions //
// --------- //
async function getFirstPages() {
const promises = allClassItems.value.map(async (item) => {
const result = await fetchFromService(
'get-paginated-records-constrained',
item.value,
allPrefixes
);
const totalItems = getTotalItems(item.value);
if (totalItems > 0) {
item.props.totalItemCount = totalItems;
}
});
await Promise.all(promises);
}
function onScrollEndOfClass(classIRI) {
console.log(`Scroll end for class: ${classIRI}`)
fetchNextPage(classIRI)
}
function afterFormsClosed() {
drawer.value = mobile.value ? false : true;
canSubmit.value = true;
classRecordsLoading.value = false;
updateURL(selectedIRI.value, false, null, allPrefixes);
}
function afterAddInstanceItem() {
if (mobile.value) drawer.value = false;
canSubmit.value = false;
updateURL(selectedIRI.value, true, null, allPrefixes);
}
function afterEditInstanceItem(editShapeIRI, editItemIdx) {
if (mobile.value) drawer.value = false;
canSubmit.value = false;
updateURL(editShapeIRI, true, editItemIdx, allPrefixes);
}
function handleBeforeUnload(event) {
if (nodesToSubmit.value.length > 0) {
event.preventDefault();
event.returnValue = '';
return '';
}
}
function scrollToTop() {
nextTick(() => {
const el = mainContent.value?.$el || mainContent.value;
if (el) el.scrollTop = 0;
});
}
function clearField() {
searchInput.value = '';
searchText.value = '';
textMatchType.value = 'partial';
}
function toggleOrder() {
orderTopDown.value = !orderTopDown.value;
if (orderTopDown.value) {
orderIcon.value = 'mdi-arrow-down-thick';
} else {
orderIcon.value = 'mdi-arrow-up-thick';
}
}
async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
// Let's document this
// Currently, this is called either when a class is selected from the side pane
// or when a class is specified via URL query parameter.
// Much of what happens on selection is specific to rendering a single class
// and its items for the conventional `ShaclVue` component.
// Apart from that, which steps are actually minimally necessary?
var tempIncludeSubs = includeSubClasses.value;
includeSubClasses.value = includeSubs ? includeSubs : false;
fetchedItemCount.value = null;
totalItemCount.value = 0
isFetchingPage.value = false;
showScrollTopBtn.value = false;
var tempSearchText = searchText.value;
var tempIRI = selectedIRI.value;
searchText.value = '';
textMatchType.value = 'partial';
selectedIRI.value = IRI;
selectedShape.value = shapesDS.data.nodeShapes[IRI];
canEditClass.value = configVarsMain.noEditClasses.indexOf(toCURIE(IRI, allPrefixes)) < 0 ? true : false;
scrollToTop();
if (fromUser) {
updateURL(IRI, false, null, allPrefixes);
}
if (!fromUser || fromBackButton) {
selectedItem.value = [IRI];
}
if (firstNavigationDone.value) {
if (!fromBackButton) {
if (IRI != tempIRI) {
internalHistory.value.push({
iri: tempIRI,
searchText: tempSearchText,
includeSubs: tempIncludeSubs,
});
}
}
} else {
firstNavigationDone.value = true;
}
if (mobile.value) {
drawer.value = false;
}
}
function onTokenDialogOpened() {
// Replace url with one where token is not included
const url = new URL(window.location);
url.searchParams.delete('token');
window.history.replaceState(null, '', url);
}
</script>
<style>
.code-style {
color: #ff0000;
background-color: #f5f5f5;
padding: 0.1em 0.2em;
font-family: monospace;
border-radius: 4px;
border: 1px solid #ddd;
}
.v-expansion-panel-text {
display: unset !important;
margin: 0 !important;
padding: 0 !important;
}
.v-expansion-panel-text__wrapper {
margin: 0 !important;
padding: 0 !important;
}
a {
color: var(--link-color);
text-decoration: none;
}
a:hover {
color: var(--hover-color);
text-decoration: underline;
}
a:visited {
color: var(--visited-color);
}
a:active {
color: var(--active-color);
}
</style>
<style scoped>
.opacity-column {
opacity: 0.5; /* Set opacity value between 0 (fully transparent) and 1 (fully opaque) */
}
.custompanels {
border: 1px solid #ccc !important; /* Change to your preferred color */
box-shadow: none !important; /* Remove elevation */
border-radius: 8px; /* Optional: Adjust border rounding */
}
.custompanels .v-expansion-panel {
border-bottom: 1px solid #ddd !important; /* Adds a subtle divider between panels */
box-shadow: none !important;
border-radius: 8px; /* Optional: Adjust border rounding */
}
.drawer-fab {
position: fixed;
top: var(--v-layout-top);
left: 0;
border-radius: 0 6px 6px 0;
box-shadow: 2px 0 6px rgba(0, 0, 0, 0.3);
z-index: 2000;
}
.row-sheet {
max-height: 30vh;
overflow: hidden;
}
.left-col {
height: 100%;
overflow-y: scroll;
}
.right-col {
height: 100%;
}
.right-col-div {
max-height: 30vh;
overflow-y: auto;
}
</style>

View file

@ -12,6 +12,7 @@
<v-textarea <v-textarea
v-else-if="input.type === 'text-paragraph'" v-else-if="input.type === 'text-paragraph'"
v-model="value" v-model="value"
:placeholder="input.placeholder"
:rules="rules" :rules="rules"
variant="outlined" variant="outlined"
density="compact" density="compact"

View file

@ -88,7 +88,7 @@ const mainVarsToLoad = {
service_base_url: [], service_base_url: [],
service_constrained_search: { service_constrained_search: {
min_characters: 4, min_characters: 4,
typing_debounce: 800, typing_debounce: 500,
}, },
class_name_display: 'name', class_name_display: 'name',
footer_links: [], footer_links: [],

View file

@ -271,23 +271,25 @@ export function useData(config) {
// First is fetching a single TTL document that expects TTL text // First is fetching a single TTL document that expects TTL text
if (expect === 'ttl') { if (expect === 'ttl') {
const text = await response.text(); const text = await response.text();
await rdfDS.parseTTLandDedup(text); let ttlData = await rdfDS.parseTTLandDedup(text);
rdfDS.triggerReactivity(); rdfDS.emitAddedRecords(ttlData.records)
return { success: true, url: getURL }; return { success: true, url: getURL };
} }
// Other response types both expect json first // Other response types both expect json first
const json = await response.json(); const json = await response.json();
// JSON array of TTL strings // JSON array of TTL strings
if (expect === 'json') { if (expect === 'json') {
let jsonRecords = [];
for (const element of json) { for (const element of json) {
await rdfDS.parseTTLandDedup(element); let jsonData = await rdfDS.parseTTLandDedup(element);
jsonRecords = jsonRecords.concat(jsonData.records)
} }
rdfDS.triggerReactivity(); rdfDS.emitAddedRecords(jsonRecords)
return { success: true, url: getURL }; return { success: true, url: getURL };
} }
// Paginated response // Paginated response
if (expect === 'paged') { if (expect === 'paged') {
let pagedRecords = [];
const metadata = { const metadata = {
page: json.page, page: json.page,
pages: json.pages, pages: json.pages,
@ -295,9 +297,10 @@ export function useData(config) {
size: json.size, size: json.size,
}; };
for (const element of json.items) { for (const element of json.items) {
await rdfDS.parseTTLandDedup(element); let pagedData = await rdfDS.parseTTLandDedup(element);
pagedRecords = pagedRecords.concat(pagedData.records)
} }
rdfDS.triggerReactivity(); rdfDS.emitAddedRecords(pagedRecords)
return { success: true, url: getURL, pageMeta: metadata }; return { success: true, url: getURL, pageMeta: metadata };
} }
throw new Error(`Unsupported fetch type: ${expect}`); throw new Error(`Unsupported fetch type: ${expect}`);

View file

@ -9,13 +9,14 @@ It contains all functionality related to the display and interactions of
records on the main page. records on the main page.
*/ */
import { ref, computed, watch, watchEffect} from 'vue'; import { ref, computed, watch, watchEffect, onMounted, nextTick, reactive} from 'vue';
import { debounce } from 'lodash-es'; import { debounce } from 'lodash-es';
import { RDF, SKOS } from '@/modules/namespaces'; import { RDF, SKOS } from '@/modules/namespaces';
import { import {
getConfigDisplayLabel, getConfigDisplayLabel,
hasConfigDisplayLabel, hasConfigDisplayLabel,
toCURIE, toCURIE,
getPidQuad,
} from '@/modules/utils'; } from '@/modules/utils';
import { DataFactory } from 'n3'; import { DataFactory } from 'n3';
const { namedNode } = DataFactory; const { namedNode } = DataFactory;
@ -38,24 +39,42 @@ export function useRecords(
// ---- // // ---- //
const totalItemCount = ref(0); const totalItemCount = ref(0);
const isFetchingPage = ref(false); const isFetchingPage = ref(false);
const showScrollTopBtn = ref(false);
const showFetchingPageLoader = ref(false) const showFetchingPageLoader = ref(false)
const searchText = ref(''); const searchText = ref('');
const textMatchType = ref('partial'); const textMatchType = ref('partial');
const instanceItemsComp = ref([]);
const newTypeSelected = ref(false);
const itemsTrigger = ref(false);
const fetchedItemCount = ref(null)
const classRecordsLoading = ref(false); const classRecordsLoading = ref(false);
const headingHover = ref(false); const headingHover = ref(false);
const orderTopDown = ref(true); const orderTopDown = ref(true);
const includeSubClasses = ref(false); const includeSubClasses = ref(false);
const includedClasses = ref(null);
let hideTimeout = null let hideTimeout = null
let debounceTypingTimer = null; let debounceTypingTimer = null;
const itemQueue = new Set();
let isProcessingItemQueue = false;
// We needed to decide between 1 and 2:
// 1: shallowRef({}), with shallow copy every time a new item is added
// records.value = {
// ...records.value,
// [pid]: item
// };
// The shallow copy is necessary in order to trigger reactivity because a (shallow?) ref does not
// track deeper reactivity.
// 2: reactive({})
// After consideration: using ref([]) and shallowRef([]) means every time a record is added we first
// need to check if it exists in the array already, which is another step that adds to the cost.
// i.e. => use reactive.
const recordItemsAll = reactive({});
const recordItemsByClass = reactive({});
// --------------------- // // --------------------- //
// Lifecycle/Vue methods // // Lifecycle/Vue methods //
// --------------------- // // --------------------- //
onMounted(() => {
rdfDS.addEventListener('recordsChanged', (e) => {
enqueueChanges(e.detail.records);
});
});
watch(isFetchingPage, (newVal) => { watch(isFetchingPage, (newVal) => {
if (newVal) { if (newVal) {
// If fetching starts → show immediately // If fetching starts → show immediately
@ -73,20 +92,6 @@ export function useRecords(
} }
}) })
watch(
instanceItemsComp,
(newVal, oldVal) => {
if (newTypeSelected.value) {
newTypeSelected.value = false;
return;
}
if (classRecordsLoading.value) {
classRecordsLoading.value = false;
}
},
{ deep: true }
);
watchEffect(async () => { watchEffect(async () => {
// If we are using a backend service AND // If we are using a backend service AND
// there are a minimum amount of characters in the search field AND // there are a minimum amount of characters in the search field AND
@ -98,7 +103,7 @@ export function useRecords(
hasUnfetchedPages(selectedIRI.value, searchText.value)) { hasUnfetchedPages(selectedIRI.value, searchText.value)) {
// Only trigger fetch if not already fetching // Only trigger fetch if not already fetching
if (!isFetchingPage.value) { if (!isFetchingPage.value) {
await fetchNextPage(searchText.value); await fetchNextPage(selectedIRI.value, searchText.value);
} }
} }
}); });
@ -112,13 +117,6 @@ export function useRecords(
onTypingPause(newVal); onTypingPause(newVal);
}, configVarsMain.serviceConstrainedSearch.typing_debounce); }, configVarsMain.serviceConstrainedSearch.typing_debounce);
}); });
// regenerate list if the graph data is updated
const debouncedUpdate = debounce(() => {
if (openForms.length == 0) {
getInstanceItems();
}
}, 500);
watch(() => rdfDS.data.graphChanged, debouncedUpdate, { deep: true });
// -------------- // // -------------- //
// Computed props // // Computed props //
@ -143,38 +141,78 @@ export function useRecords(
} }
}) })
const filteredInstanceItemsComp = computed(() => { const showScrollTopBtn = computed(() => {
if (filteredRecordItemsByClass.value[selectedIRI.value]?.length > 7) return true;
return false;
});
const fetchedItemCount = computed(() => {
if (includeSubClasses.value && Array.isArray(allSubClasses[selectedIRI.value]) && allSubClasses[selectedIRI.value].length > 0 ) {
let allclass_array = [selectedIRI.value].concat(allSubClasses[selectedIRI.value])
let itemCount = 0;
for (const cl of allclass_array) {
if (recordItemsByClass[cl]) {
itemCount += Object.values(recordItemsByClass[cl]).length;
}
}
return itemCount;
}
if (recordItemsByClass[selectedIRI.value]) {
return Object.values(recordItemsByClass[selectedIRI.value]).length;
}
return null;
});
const filteredRecordItemsAll = computed(() => {
let txt = searchText.value.toLowerCase().trim(); let txt = searchText.value.toLowerCase().trim();
if (txt.length == 0) return sortItems(Object.values(recordItemsAll))
return sortItems( return sortItems(
[...instanceItemsComp.value].filter((item) => { Object.values(recordItemsAll).filter((item) => {
if (txt.length == 0) return true; if (txt.length == 0) return true;
return searchableFields.some((field) => { if (!('_searchBlob' in item.props)) return false;
if (!(field in item.props)) return false; return item.props._searchBlob.includes(txt);
const value = item.props[field]?.toString().toLowerCase().trim();
if (Array.isArray(value)) {
return value.some((val) => {
return val.includes(txt);
})
} else {
return value.includes(txt);
}
});
}) })
) )
}); });
const matchedInstanceItemsComp = computed(() => { const filteredRecordItemsByClass = computed(() => {
let txt = searchText.value.toLowerCase().trim(); let txt = searchText.value.toLowerCase().trim();
return sortItems( const map = {};
[...instanceItemsComp.value].filter((item) => { for (const cl of Object.keys(recordItemsByClass)) {
if (txt.length == 0) return true; map[cl] = sortItems(
return searchableFields.some((field) => { Object.values(recordItemsByClass[cl]).filter((item) => {
if (!(field in item.props)) return false; if (txt.length == 0) return true;
const value = item.props[field]?.toString().toLowerCase().trim(); if (!('_searchBlob' in item.props)) return false;
return value === txt; if (textMatchType.value == 'exact') {
}); return searchableFields.some((field) => {
}) if (!(field in item.props)) return false;
) const value = item.props[field]?.toString().toLowerCase().trim();
return value === txt;
});
} else {
return item.props._searchBlob.includes(txt);
}
})
)
}
return map
});
const filteredRecordItemsForClassWithSubclassItems = computed(() => {
let items = [];
if (!includeSubClasses.value) {
return items;
}
if (Array.isArray(allSubClasses[selectedIRI.value]) && allSubClasses[selectedIRI.value].length > 0 ) {
let allclass_array = [selectedIRI.value].concat(allSubClasses[selectedIRI.value])
for (const cl of allclass_array) {
if (filteredRecordItemsByClass.value[cl]) {
items = items.concat(filteredRecordItemsByClass.value[cl])
}
}
}
return sortItems(items)
}); });
// --------- // // --------- //
@ -183,22 +221,21 @@ export function useRecords(
// fetch new items at bottom of scroller // fetch new items at bottom of scroller
function onScrollEnd() { function onScrollEnd() {
debouncedScrollEnd(); debouncedScrollEnd(selectedIRI.value);
} }
const debouncedScrollEnd = debounce(async () => { const debouncedScrollEnd = debounce(async (classIRI) => {
// Only fetch new items at bottom of scroller if there is not any search text // Only fetch new items at bottom of scroller if there is not any search text
// Continued fetching of more items while there is search text will be handled // Continued fetching of more items while there is search text will be handled
// by the watcheffect function. // by the watcheffect function.
if (searchText.value) { if (searchText.value) {
return return
} }
if (config.value.use_service) { if (config.value.use_service) {
if (hasUnfetchedPages(selectedIRI.value) && !isFetchingPage.value) { if (hasUnfetchedPages(classIRI) && !isFetchingPage.value) {
await fetchNextPage(); await fetchNextPage(classIRI);
} else { } else {
console.log("Last page already fetched") console.log(`Last page already fetched: ${classIRI}`)
} }
} }
}, 1000); }, 1000);
@ -211,7 +248,7 @@ export function useRecords(
async function onTypingPause(textVal) { async function onTypingPause(textVal) {
if (!searchText.value || searchText.value.length < configVarsMain.serviceConstrainedSearch.min_characters ) return; if (!searchText.value || searchText.value.length < configVarsMain.serviceConstrainedSearch.min_characters ) return;
await fetchNextPage(searchText.value); await fetchNextPage(selectedIRI.value, searchText.value);
} }
// User types, debounce effect monitors pauses and waits for configured time // User types, debounce effect monitors pauses and waits for configured time
// before making the first constrained request. // before making the first constrained request.
@ -225,20 +262,19 @@ export function useRecords(
// Continued fetching of more items while there is search text is handled by // Continued fetching of more items while there is search text is handled by
// the watcheffect function. // the watcheffect function.
async function fetchNextPage(matchText='') { async function fetchNextPage(classIRI, matchText='') {
if (isFetchingPage.value || !hasUnfetchedPages(selectedIRI.value, matchText)) return; if (isFetchingPage.value || !hasUnfetchedPages(classIRI, matchText)) return;
isFetchingPage.value = true; isFetchingPage.value = true;
try { try {
const result = await fetchFromService( const result = await fetchFromService(
'get-paginated-records-constrained', 'get-paginated-records-constrained',
selectedIRI.value, classIRI,
allPrefixes, allPrefixes,
matchText matchText
); );
if (result.status === null) { if (result.status === null) {
console.error(result.error); console.error(result.error);
} }
getInstanceItems(); // rebuild local list of items
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
@ -246,100 +282,6 @@ export function useRecords(
} }
} }
function getInstanceItems() {
// ---
// The goal of this method is to populate the list of data objects of the selected type
// ---
var x = itemsTrigger.value;
if (!selectedIRI.value) {
return [];
}
// find nodes with triple predicate == rdf:type, and triple object == the selected class
// if the class is a configured priority class with include_subclasses = true, find nodes
// for the selected class and all of its subclasses
var quads;
if (includeSubClasses.value) {
let allclass_array = [selectedIRI.value]
if (Array.isArray(allSubClasses[selectedIRI.value]) && allSubClasses[selectedIRI.value].length > 0 ) {
allclass_array = allclass_array.concat(allSubClasses[selectedIRI.value])
}
quads = [];
for (const cl of allclass_array) {
const mySubArray = rdfDS.getLiteralAndNamedNodes(
namedNode(RDF.type.value),
cl,
allPrefixes
)
quads = quads.concat(mySubArray);
}
} else {
quads = rdfDS.getLiteralAndNamedNodes(
namedNode(RDF.type.value),
selectedIRI.value,
allPrefixes
);
}
// Create list items from quads
var instanceItemsArr = [];
quads.forEach((quad) => {
var extra = '';
if (quad.subject.termType === 'BlankNode') {
extra = ' (BlankNode)';
}
var relatedTrips = rdfDS.getSubjectTriples(quad.subject);
var item = {
title: quad.subject.value + extra,
value: quad.subject.value,
props: {
subtitle: quad.object.value,
quad: quad,
itemValue: quad.subject.value,
},
};
let labelTemplate = hasConfigDisplayLabel(quad.object.value, allPrefixes, configVarsMain)
let labelParts = {}
relatedTrips.forEach((quad) => {
if (!Object.hasOwn(item.props, quad.predicate.value)) {
item.props[quad.predicate.value] = [];
}
if (quad.object.termType === 'BlankNode') {
var bnItem = {};
var blankNodeTrips = rdfDS.getSubjectTriples(quad.object);
blankNodeTrips.forEach((bnquad) => {
bnItem[bnquad.predicate.value] = bnquad.object.value;
});
item.props[quad.predicate.value].push(bnItem);
} else {
item.props[quad.predicate.value].push(quad.object.value);
}
let predCuri = toCURIE(quad.predicate.value, allPrefixes)
// If current predicate is used for display label generation, store it
if ( labelTemplate && labelTemplate.includes(predCuri)) {
if (!labelParts[predCuri]) {
labelParts[predCuri] = []
}
labelParts[predCuri].push(quad.object.value)
}
});
item.props._prefLabel = '';
if (item.props.hasOwnProperty(SKOS.prefLabel.value)) {
item.props._prefLabel = item.props[SKOS.prefLabel.value][0];
}
// Generate display label if possible
item.props._displayLabel = '';
if (labelTemplate) {
let displayLabel = getConfigDisplayLabel(labelTemplate, labelParts, configVarsMain, rdfDS, allPrefixes)
if (displayLabel) {
item.props._displayLabel = displayLabel;
}
}
instanceItemsArr.push(item);
});
instanceItemsComp.value = [...instanceItemsArr];
if (instanceItemsComp.value.length > 7) showScrollTopBtn.value = true;
fetchedItemCount.value = instanceItemsComp.value.length;
}
function getSortValue(item) { function getSortValue(item) {
for (const field of searchableFields) { for (const field of searchableFields) {
const value = item.props[field]; const value = item.props[field];
@ -364,6 +306,110 @@ export function useRecords(
}) })
} }
function enqueueChanges(records) {
for (const r of records) {
itemQueue.add(r);
}
processQueue();
}
async function processQueue() {
if (isProcessingItemQueue) return;
isProcessingItemQueue = true;
while (itemQueue.size > 0) {
// take a batch
const batch = Array.from(itemQueue).slice(0, 10);
batch.forEach((record) =>{
itemQueue.delete(record)
updateRecordItem(record)
});
// yield to UI thread
await nextTick();
}
isProcessingItemQueue = false;
}
function updateRecordItem(record) {
// This function does not care if a record item already exists in the
// object; it builds the item and adds it nevertheless.
// First we get the record's quad
let mainQuad = getPidQuad(record, rdfDS.data.graph);
if (!mainQuad) {
console.log(`No PID quad found in graph for record: ${record}; skipping update of this item`)
return
}
let recordClass = mainQuad.object.value;
// Now get related quads
var relatedTrips = rdfDS.getSubjectTriples(mainQuad.subject);
// Initialize item
var item = {
title: record,
value: record,
props: {
subtitle: recordClass,
quad: mainQuad,
itemValue: record,
},
};
let labelTemplate = hasConfigDisplayLabel(recordClass, allPrefixes, configVarsMain)
let labelParts = {}
relatedTrips.forEach((quad) => {
if (!Object.hasOwn(item.props, quad.predicate.value)) {
item.props[quad.predicate.value] = [];
}
if (quad.object.termType === 'BlankNode') {
var bnItem = {};
var blankNodeTrips = rdfDS.getSubjectTriples(quad.object);
blankNodeTrips.forEach((bnquad) => {
bnItem[bnquad.predicate.value] = bnquad.object.value;
});
item.props[quad.predicate.value].push(bnItem);
} else {
item.props[quad.predicate.value].push(quad.object.value);
}
let predCuri = toCURIE(quad.predicate.value, allPrefixes)
// If current predicate is used for display label generation, store it
if ( labelTemplate && labelTemplate.includes(predCuri)) {
if (!labelParts[predCuri]) {
labelParts[predCuri] = []
}
labelParts[predCuri].push(quad.object.value)
}
});
item.props._prefLabel = '';
if (item.props.hasOwnProperty(SKOS.prefLabel.value)) {
item.props._prefLabel = item.props[SKOS.prefLabel.value][0];
}
// Generate display label if possible
item.props._displayLabel = '';
if (labelTemplate) {
let displayLabel = getConfigDisplayLabel(labelTemplate, labelParts, configVarsMain, rdfDS, allPrefixes)
if (displayLabel) {
item.props._displayLabel = displayLabel;
}
}
// Now put together single searchable blob
item.props._searchBlob = ''
for (const field of searchableFields) {
if (!(field in item.props)) continue;
let value = item.props[field]?.toString().toLowerCase().trim();
if (!Array.isArray(value)) {
value = [value]
}
for (const v of value) {
item.props._searchBlob = item.props._searchBlob + v
}
}
// Now that we have the complete item, we can add it to the tracking objects
// Class records tracker
if (!recordItemsByClass.hasOwnProperty(recordClass)) {
recordItemsByClass[recordClass] = {};
}
recordItemsByClass[recordClass][record] = item;
// All records tracker
recordItemsAll[record] = item;
}
// ------- // // ------- //
// Returns // // Returns //
// ------- // // ------- //
@ -371,14 +417,11 @@ export function useRecords(
classRecordsLoading, classRecordsLoading,
currentProgress, currentProgress,
fetchedItemCount, fetchedItemCount,
filteredInstanceItemsComp, fetchNextPage,
getInstanceItems,
headingHover, headingHover,
includedClasses,
includeSubClasses, includeSubClasses,
instanceItemsComp,
isFetchingPage, isFetchingPage,
matchedInstanceItemsComp,
newTypeSelected,
onScrollEnd, onScrollEnd,
onUserTyping, onUserTyping,
orderTopDown, orderTopDown,
@ -388,5 +431,9 @@ export function useRecords(
showScrollTopBtn, showScrollTopBtn,
textMatchType, textMatchType,
totalItemCount, totalItemCount,
recordItemsByClass,
filteredRecordItemsAll,
filteredRecordItemsByClass,
filteredRecordItemsForClassWithSubclassItems,
}; };
} }

View file

@ -3,9 +3,9 @@
* @description This composable reads a ttl file with shacl shapes and returns * @description This composable reads a ttl file with shacl shapes and returns
* a set of reactive variables used by the root application component * a set of reactive variables used by the root application component
*/ */
import { reactive, toRaw} from 'vue'; import { reactive, toRaw, ref} from 'vue';
import { ShapesDataset } from 'shacl-tulip'; import { ShapesDataset } from 'shacl-tulip';
import { findObjectByKey, toIRI } from '@/modules/utils' import { findObjectByKey, toIRI, toCURIE, getDisplayName, includeClass} from '@/modules/utils'
import { SHACL, RDFS} from '@/modules/namespaces'; import { SHACL, RDFS} from '@/modules/namespaces';
const basePath = import.meta.env.BASE_URL || '/'; const basePath = import.meta.env.BASE_URL || '/';
@ -16,6 +16,14 @@ export function useShapes(config) {
// ---- // // ---- //
const defaultURL = `${basePath}dlschemas_shacl.ttl`; const defaultURL = `${basePath}dlschemas_shacl.ttl`;
const shapesDS = new ShapesDataset(reactive({})); const shapesDS = new ShapesDataset(reactive({}));
// These are all arays of classes that are eventually represented in the
// main class-selection pane in the ShaclVue* components
const idFilteredNodeShapeNames = ref([]);
const noEditClassList = ref([]);
const filteredNodeShapeNames = ref([]);
const priorityFilteredNodeShapeNames = ref([]);
const orderedNodeShapeNames = ref([]);
const allClassItems = ref([]);
// ----------------- // // ----------------- //
// Lifecycle methods // // Lifecycle methods //
@ -135,6 +143,138 @@ export function useShapes(config) {
shapesDS.data.propertyGroups['_default'][SHACL.order.value] = high_order + 100; shapesDS.data.propertyGroups['_default'][SHACL.order.value] = high_order + 100;
} }
const getIdFilteredNodeShapeNames = ((configVarsMain, ID_IRI) => {
if (configVarsMain.showShapesWoId === true) {
return shapesDS.data.nodeShapeNamesArray;
}
var shapeNames = [];
for (var n of shapesDS.data.nodeShapeNamesArray) {
if (
findObjectByKey(
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[n]].properties,
SHACL.path.value,
ID_IRI.value
)
) {
shapeNames.push(n);
}
}
return shapeNames;
});
const getNoEditClassList = ((configVarsMain, allPrefixes) => {
if (configVarsMain.noEditClasses?.length == 0) return []
var names = idFilteredNodeShapeNames.value;
var shapeNames = [];
for (var n of names) {
// First get IRI and prefix
var n_iri = shapesDS.data.nodeShapeNames[n]
if (includeClass(n_iri, configVarsMain, allPrefixes) &&
configVarsMain.noEditClasses?.indexOf(toCURIE(n_iri, allPrefixes)) >= 0) {
shapeNames.push(n);
}
}
return shapeNames.sort((a, b) =>
getDisplayName(
shapesDS.data.nodeShapeNames[a],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[a]]
).toLowerCase()
.localeCompare(
getDisplayName(
shapesDS.data.nodeShapeNames[b],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[b]]
).toLowerCase()
)
);
});
const getFilteredNodeShapeNames = ((configVarsMain, allPrefixes) => {
var names = idFilteredNodeShapeNames.value;
// If all relevant config arrays are empty, show all classes
if (
configVarsMain.showClasses?.length == 0 &&
configVarsMain.showClassesWithPrefix?.length == 0 &&
configVarsMain.hideClasses?.length == 0 &&
configVarsMain.hideClassesWithPrefix?.length == 0 &&
configVarsMain.noEditClasses?.length == 0
) {
return names;
}
var shapeNames = [];
for (var n of names) {
// First get IRI and prefix
var n_iri = shapesDS.data.nodeShapeNames[n]
if (includeClass(n_iri, configVarsMain, allPrefixes) && configVarsMain.noEditClasses.indexOf(toCURIE(n_iri, allPrefixes)) < 0) {
shapeNames.push(n);
}
}
return shapeNames;
});
const getPriorityFilteredNodeShapeNames = ((priorityClassList) => {
var names = filteredNodeShapeNames.value;
var shapeNames = [];
for (var n of names) {
var n_iri = shapesDS.data.nodeShapeNames[n]
if (!priorityClassList.value.includes(n_iri)) {
shapeNames.push(n);
}
}
return shapeNames;
})
const getOrderedNodeShapeNames = ((configVarsMain, allPrefixes) => {
return priorityFilteredNodeShapeNames.value.sort((a, b) =>
getDisplayName(
shapesDS.data.nodeShapeNames[a],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[a]]
).toLowerCase()
.localeCompare(
getDisplayName(
shapesDS.data.nodeShapeNames[b],
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[shapesDS.data.nodeShapeNames[b]]
).toLowerCase()
)
);
})
const getAllClassItems = ((configVarsMain, allPrefixes, getClassIcon) => {
let items = [];
for (const node of orderedNodeShapeNames.value) {
const classIRI = shapesDS.data.nodeShapeNames[node];
const displayName = getDisplayName(
classIRI,
configVarsMain,
allPrefixes,
shapesDS.data.nodeShapes[classIRI]
);
const description = shapesDS.data.nodeShapes[classIRI][RDFS.comment.value];
items.push(
{
title: displayName,
value: classIRI,
props: {
title: displayName,
iri: classIRI,
subtitle: toCURIE(classIRI, allPrefixes),
icon: getClassIcon(classIRI),
description: description,
totalItemCount: null,
},
}
)
}
return items;
})
// ------- // // ------- //
// Returns // // Returns //
// ------- // // ------- //
@ -144,5 +284,17 @@ export function useShapes(config) {
updateShapesFromDefault, updateShapesFromDefault,
updateShapes, updateShapes,
updatePropertyGroups, updatePropertyGroups,
idFilteredNodeShapeNames,
noEditClassList,
filteredNodeShapeNames,
priorityFilteredNodeShapeNames,
orderedNodeShapeNames,
allClassItems,
getIdFilteredNodeShapeNames,
getNoEditClassList,
getFilteredNodeShapeNames,
getPriorityFilteredNodeShapeNames,
getOrderedNodeShapeNames,
getAllClassItems,
}; };
} }

View file

@ -1,5 +1,5 @@
import { ref, reactive, toRaw} from "vue"; import { ref, reactive, toRaw } from "vue";
import { fillStringTemplate, findObjectByKey, findObjectIndexByKey, nodeShapeHasProperty} from "@/modules/utils"; import { fillStringTemplate, findObjectByKey, findObjectIndexByKey, nodeShapeHasProperty } 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';
@ -8,10 +8,11 @@ import { useNunjucks } from "@/composables/useNunjucks";
const { fillNunjucksTemplate } = useNunjucks(); const { fillNunjucksTemplate } = useNunjucks();
export function showWizardGroup(configVarsMain, context, classUri, allPrefixes, shapesDS) { export function showWizardGroup(configVarsMain, context, classUri, allPrefixes, shapesDS) {
console.log("Checking if wizard group should be shown")
const classCurie = toCURIE(classUri, allPrefixes); const classCurie = toCURIE(classUri, allPrefixes);
// all classes wizards
const all_class_selection = context == '_class' && configVarsMain.wizardEditorSelection?._classes;
// class-based wizards ? // class-based wizards ?
const selection = configVarsMain.wizardEditorSelection?.[classCurie]?.[context] const selection = configVarsMain.wizardEditorSelection?.[classCurie]?.[context];
// slot-based wizards ? // slot-based wizards ?
let slot_selection = false; let slot_selection = false;
if (configVarsMain.wizardEditorSelection?._slots) { if (configVarsMain.wizardEditorSelection?._slots) {
@ -27,7 +28,7 @@ export function showWizardGroup(configVarsMain, context, classUri, allPrefixes,
} }
} }
} }
const rval = slot_selection || selection && Array.isArray(selection) && selection.length > 0; const rval = all_class_selection || slot_selection || selection && Array.isArray(selection) && selection.length > 0;
return rval return rval
} }
@ -52,7 +53,13 @@ export function useWizard() {
let classCurie = toCURIE(class_IRI, allPrefixes) let classCurie = toCURIE(class_IRI, allPrefixes)
// Load wizard editors if any // Load wizard editors if any
let wizardsToAdd = new Set(); let wizardsToAdd = new Set();
// First, class-specific wizards // First, any wizards that should show for all classes
if (context == '_class' && configVarsMain.wizardEditorSelection?._classes){
for (const wizard of configVarsMain.wizardEditorSelection?._classes) {
wizardsToAdd.add(wizard)
}
}
// then class-specific wizards
if (configVarsMain.wizardEditorSelection?.[classCurie]?.[context]){ if (configVarsMain.wizardEditorSelection?.[classCurie]?.[context]){
for (const wizard of configVarsMain.wizardEditorSelection?.[classCurie]?.[context] || []) { for (const wizard of configVarsMain.wizardEditorSelection?.[classCurie]?.[context] || []) {
wizardsToAdd.add(wizard) wizardsToAdd.add(wizard)
@ -90,6 +97,8 @@ export function useWizard() {
async function handleWizardSave(context, class_uri, wizardData, rdfDS, savedNodes, nodesToSubmit, subject_uri=null, formData) { async function handleWizardSave(context, class_uri, wizardData, rdfDS, savedNodes, nodesToSubmit, subject_uri=null, formData) {
wizardDialog.value = false; wizardDialog.value = false;
selectedWizard.value = null; selectedWizard.value = null;
// Add class uri to wizard data
wizardData.class_uri = class_uri;
// if the context is '_record', add the current formData node ID as "pid" // if the context is '_record', add the current formData node ID as "pid"
if (context == '_record') { if (context == '_record') {
wizardData.pid = subject_uri; wizardData.pid = subject_uri;
@ -103,12 +112,11 @@ export function useWizard() {
} }
// And then parse TTL, adding quads to graph data // And then parse TTL, adding quads to graph data
let newQuads = await rdfDS.parseTTLandDedup(newTTL); let newQuads = await rdfDS.parseTTLandDedup(newTTL);
rdfDS.triggerReactivity();
// Now we process each added quad differently based on context: // Now we process each added quad differently based on context:
// if context is _record, we need to work with formData of current record being edited // if context is _record, we need to work with formData of current record being edited
// if context is _class or higher level, we can ignore formData because everything happens via template // if context is _class or higher level, we can ignore formData because everything happens via template
if (context == '_record') { if (context == '_record') {
for (const q of newQuads) { for (const q of newQuads.quads) {
// If the quad has the current node ID as subject, we need to add it to formdata, and also remove the quad from graph store // If the quad has the current node ID as subject, we need to add it to formdata, and also remove the quad from graph store
// If the quad has a different named node as subject, we need to keep track of it for submission purposes // If the quad has a different named node as subject, we need to keep track of it for submission purposes
if (q.subject.value == subject_uri) { if (q.subject.value == subject_uri) {
@ -140,8 +148,8 @@ export function useWizard() {
} }
} }
} else { } else {
console.log("The context was not _record...") rdfDS.emitAddedRecords(newQuads.records)
for (const q of newQuads) { for (const q of newQuads.quads) {
// Here we do not have to keep track of quads added to the graph, // Here we do not have to keep track of quads added to the graph,
// because there's no parent form that can still be cancelled. // because there's no parent form that can still be cancelled.
// We need to keep track of the named nodes saved to the graph, for submission // We need to keep track of the named nodes saved to the graph, for submission

View file

@ -108,6 +108,14 @@ export function addCodeTagsToText(text, prepend, append) {
return result; return result;
} }
export function truncateText(str, n, useWordBoundary){
if (str.length <= n) { return str; }
const subString = str.slice(0, n-1);
return (useWordBoundary
? subString.slice(0, subString.lastIndexOf(" "))
: subString) + "&hellip;";
};
export function findObjectByKey(array, key, value) { export function findObjectByKey(array, key, value) {
return array.find((obj) => obj[key] === value); return array.find((obj) => obj[key] === value);
} }
@ -621,6 +629,33 @@ export function collectBlankNodeHierarchy(store, rootBNode) {
return collected; return collected;
} }
export function getRootNodes(store, node, visited = new Set()) {
if (visited.has(node.value)) return [];
visited.add(node.value);
const parents = store.getQuads(null, null, node, null);
let roots = [];
for (const p of parents) {
if (p.subject.termType === 'NamedNode') {
roots.push(p.subject.value);
} else {
roots = roots.concat(getRootNodes(store, p.subject, visited));
}
}
return roots;
}
export function getUniqueRootNodes(quads, store) {
let uniqueRecords = new Set();
for (const q of quads) {
if (q.subject.termType === 'NamedNode') {
uniqueRecords.add(q.subject.value);
} else {
let rootNodeValues = getRootNodes(store, q.subject)
for (const r of rootNodeValues) uniqueRecords.add(r)
}
}
return Array.from(uniqueRecords)
}
export function getRecordQuads(pid, graph, recursive=false) { export function getRecordQuads(pid, graph, recursive=false) {
// Return an array of quads related to a specific named node // Return an array of quads related to a specific named node