New approach to generating/maintaining list of records + ShaclVueStarter feature #349
16 changed files with 1866 additions and 322 deletions
12
src/App.vue
12
src/App.vue
|
|
@ -1,15 +1,23 @@
|
|||
<template>
|
||||
<v-app>
|
||||
<ShaclVue></ShaclVue>
|
||||
<!-- <ShaclVue :configUrl="confURL"></ShaclVue> -->
|
||||
<!-- <component :is="appVariant" /> -->
|
||||
<component :is="appVariant" :configUrl="confURL"></component>
|
||||
</v-app>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineAsyncComponent } from 'vue';
|
||||
// A specific config URL can be provided:
|
||||
// const confURL = '';
|
||||
// If not provided, the default config URLs will be tried in order at the base URL:
|
||||
// 1. config.yaml
|
||||
// 2. config.yml
|
||||
// 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>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { reactive, ref } from 'vue';
|
|||
import { RdfDataset } from 'shacl-tulip';
|
||||
import { DataFactory, Store, Parser } from 'n3';
|
||||
import { RDF } from '@/modules/namespaces';
|
||||
import { hashSubgraph, getNodeContextKey, collectBlankNodeHierarchy} from '@/modules/utils';
|
||||
import { hashSubgraph, getNodeContextKey, collectBlankNodeHierarchy, getUniqueRootNodes} from '@/modules/utils';
|
||||
const { blankNode} = DataFactory;
|
||||
|
||||
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) {
|
||||
let uniqueRecords
|
||||
const parser = new Parser();
|
||||
const tempStore = new Store();
|
||||
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.');
|
||||
let bnQuads = tempStore.getQuads(null, null, null, null)
|
||||
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
|
||||
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)
|
||||
);
|
||||
// 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
|
||||
if (!this.data.subgraphFingerprintsByRoot.has(root_node)) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
return addedQuads;
|
||||
uniqueRecords = getUniqueRootNodes(addedQuads, this.data.graph)
|
||||
return {quads: addedQuads, records: uniqueRecords}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -368,6 +368,8 @@ async function saveForm() {
|
|||
)
|
||||
) {
|
||||
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);
|
||||
if (nodesToSubmit.value.length) {
|
||||
|
|
|
|||
|
|
@ -90,6 +90,7 @@ const templates = reactive({
|
|||
})
|
||||
const hasCreatedQuads = ref(false)
|
||||
const createdQuads = ref([])
|
||||
const createdRecords = ref([])
|
||||
const createdDistributions = new Set()
|
||||
const savedNodes = inject('savedNodes');
|
||||
const nodesToSubmit = inject('nodesToSubmit');
|
||||
|
|
@ -163,9 +164,12 @@ async function onUploadComplete(result) {
|
|||
TTLdata.pid = toIRI(TTLdata.pid, allPrefixes)
|
||||
let newTTL = fillStringTemplate(templates.ttl, TTLdata)
|
||||
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
|
||||
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;
|
||||
// Keep track of distributions that were added
|
||||
createdDistributions.add(hash)
|
||||
|
|
@ -204,6 +208,8 @@ onBeforeUnmount( () => {
|
|||
}
|
||||
}
|
||||
}
|
||||
// we also emit the added records
|
||||
rdfDS.emitAddedRecords(createdRecords.value)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
|
|
|||
392
src/components/NodeShapeViewerMini.vue
Normal file
392
src/components/NodeShapeViewerMini.vue
Normal 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> 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> 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>
|
||||
|
|
@ -135,11 +135,10 @@
|
|||
<ShaclVueRecords
|
||||
:selectedIRI="selectedIRI"
|
||||
:classRecordsLoading="classRecordsLoading"
|
||||
:instanceItemsComp="instanceItemsComp"
|
||||
:mobile="mobile"
|
||||
:showScrollTopBtn="showScrollTopBtn"
|
||||
:matchedInstanceItemsComp="matchedInstanceItemsComp"
|
||||
:filteredInstanceItemsComp="filteredInstanceItemsComp"
|
||||
:filteredRecords="(includeSubClasses ? filteredRecordItemsForClassWithSubclassItems : filteredRecordItemsByClass[selectedIRI]) || []"
|
||||
:fetchedItemCount="fetchedItemCount"
|
||||
:showFetchingPageLoader="showFetchingPageLoader"
|
||||
v-model:searchText="searchText"
|
||||
v-model:textMatchType="textMatchType"
|
||||
|
|
@ -337,7 +336,25 @@ const {
|
|||
// Classes from OWL
|
||||
const { classDS, getClassData, allSubClasses, processSubClasses} = useClasses(config);
|
||||
// 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
|
||||
const {
|
||||
fetchedPages,
|
||||
|
|
@ -358,14 +375,9 @@ const {
|
|||
classRecordsLoading,
|
||||
currentProgress,
|
||||
fetchedItemCount,
|
||||
filteredInstanceItemsComp,
|
||||
getInstanceItems,
|
||||
headingHover,
|
||||
includeSubClasses,
|
||||
instanceItemsComp,
|
||||
isFetchingPage,
|
||||
matchedInstanceItemsComp,
|
||||
newTypeSelected,
|
||||
onScrollEnd,
|
||||
onUserTyping,
|
||||
orderTopDown,
|
||||
|
|
@ -375,6 +387,10 @@ const {
|
|||
showScrollTopBtn,
|
||||
textMatchType,
|
||||
totalItemCount,
|
||||
recordItemsByClass,
|
||||
filteredRecordItemsAll,
|
||||
filteredRecordItemsByClass,
|
||||
filteredRecordItemsForClassWithSubclassItems,
|
||||
} = useRecords(
|
||||
allPrefixes,
|
||||
allSubClasses,
|
||||
|
|
@ -408,7 +424,6 @@ const {
|
|||
onEditInstanceItem: afterEditInstanceItem,
|
||||
onAddForm: scrollToTop,
|
||||
onRemoveForm: afterFormsClosed, // will run when last form is closed
|
||||
onRemoveFormSaved: getInstanceItems, // will run when last form is closed via save
|
||||
}
|
||||
});
|
||||
// App navigation
|
||||
|
|
@ -521,6 +536,13 @@ watch(
|
|||
processSearchableFields();
|
||||
// Set component states from URL query parameters
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
|
@ -538,111 +560,6 @@ onBeforeUnmount(() => {
|
|||
// -------------- //
|
||||
// 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 //
|
||||
|
|
@ -688,7 +605,6 @@ async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
|
|||
totalItemCount.value = 0
|
||||
isFetchingPage.value = false;
|
||||
showScrollTopBtn.value = false;
|
||||
newTypeSelected.value = true;
|
||||
var tempSearchText = searchText.value;
|
||||
var tempIRI = selectedIRI.value;
|
||||
searchText.value = '';
|
||||
|
|
@ -708,10 +624,8 @@ async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
|
|||
console.error(result.error);
|
||||
classRecordsLoading.value = false;
|
||||
}
|
||||
// If any of the results were successful, don't set classRecordsLoading to false
|
||||
// because it will be set during the watch event for instanceItemsComp
|
||||
// If any of the results were successful, do nothing
|
||||
if (result.status.length && result.status.indexOf('success') >= 0) {
|
||||
// do nothing
|
||||
} else {
|
||||
classRecordsLoading.value = false;
|
||||
}
|
||||
|
|
@ -723,7 +637,7 @@ async function selectType(IRI, fromUser, fromBackButton, includeSubs=false) {
|
|||
totalItemCount.value = totalItems
|
||||
}
|
||||
}
|
||||
getInstanceItems();
|
||||
classRecordsLoading.value = false
|
||||
scrollToTop();
|
||||
if (fromUser) {
|
||||
updateURL(IRI, false, null, allPrefixes);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
<v-skeleton-loader type="list-item-avatar"></v-skeleton-loader>
|
||||
</span>
|
||||
<span v-else>
|
||||
<div v-if="props.instanceItemsComp.length">
|
||||
<v-row v-if="props.instanceItemsComp.length">
|
||||
<div v-if="props.fetchedItemCount">
|
||||
<v-row v-if="props.fetchedItemCount">
|
||||
<v-col :cols="props.mobile ? 12 : 8">
|
||||
<v-text-field
|
||||
v-model="searchText"
|
||||
|
|
@ -64,11 +64,7 @@
|
|||
</template>
|
||||
</v-tooltip>
|
||||
<DynamicScroller
|
||||
:items="
|
||||
textMatchType == 'exact' ?
|
||||
props.matchedInstanceItemsComp :
|
||||
props.filteredInstanceItemsComp
|
||||
"
|
||||
:items="props.filteredRecords"
|
||||
page-mode
|
||||
:min-item-size="50"
|
||||
key-field="title"
|
||||
|
|
@ -123,13 +119,12 @@ import {
|
|||
// ----- //
|
||||
const props = defineProps({
|
||||
selectedIRI: String,
|
||||
instanceItemsComp: Array,
|
||||
classRecordsLoading: Boolean,
|
||||
mobile: Boolean,
|
||||
showScrollTopBtn: Boolean,
|
||||
scrollToTop: Function,
|
||||
matchedInstanceItemsComp: Array,
|
||||
filteredInstanceItemsComp: Array,
|
||||
filteredRecords: Array,
|
||||
fetchedItemCount: Number,
|
||||
showFetchingPageLoader: Boolean,
|
||||
});
|
||||
|
||||
|
|
|
|||
125
src/components/ShaclVueRecordsMini.vue
Normal file
125
src/components/ShaclVueRecordsMini.vue
Normal 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>
|
||||
841
src/components/ShaclVueStarter.vue
Normal file
841
src/components/ShaclVueStarter.vue
Normal 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>
|
||||
|
|
@ -12,6 +12,7 @@
|
|||
<v-textarea
|
||||
v-else-if="input.type === 'text-paragraph'"
|
||||
v-model="value"
|
||||
:placeholder="input.placeholder"
|
||||
:rules="rules"
|
||||
variant="outlined"
|
||||
density="compact"
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ const mainVarsToLoad = {
|
|||
service_base_url: [],
|
||||
service_constrained_search: {
|
||||
min_characters: 4,
|
||||
typing_debounce: 800,
|
||||
typing_debounce: 500,
|
||||
},
|
||||
class_name_display: 'name',
|
||||
footer_links: [],
|
||||
|
|
|
|||
|
|
@ -271,23 +271,25 @@ export function useData(config) {
|
|||
// First is fetching a single TTL document that expects TTL text
|
||||
if (expect === 'ttl') {
|
||||
const text = await response.text();
|
||||
await rdfDS.parseTTLandDedup(text);
|
||||
rdfDS.triggerReactivity();
|
||||
let ttlData = await rdfDS.parseTTLandDedup(text);
|
||||
rdfDS.emitAddedRecords(ttlData.records)
|
||||
return { success: true, url: getURL };
|
||||
}
|
||||
// Other response types both expect json first
|
||||
const json = await response.json();
|
||||
// JSON array of TTL strings
|
||||
if (expect === 'json') {
|
||||
|
||||
let jsonRecords = [];
|
||||
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 };
|
||||
}
|
||||
// Paginated response
|
||||
if (expect === 'paged') {
|
||||
let pagedRecords = [];
|
||||
const metadata = {
|
||||
page: json.page,
|
||||
pages: json.pages,
|
||||
|
|
@ -295,9 +297,10 @@ export function useData(config) {
|
|||
size: json.size,
|
||||
};
|
||||
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 };
|
||||
}
|
||||
throw new Error(`Unsupported fetch type: ${expect}`);
|
||||
|
|
|
|||
|
|
@ -9,13 +9,14 @@ It contains all functionality related to the display and interactions of
|
|||
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 { RDF, SKOS } from '@/modules/namespaces';
|
||||
import {
|
||||
getConfigDisplayLabel,
|
||||
hasConfigDisplayLabel,
|
||||
toCURIE,
|
||||
getPidQuad,
|
||||
} from '@/modules/utils';
|
||||
import { DataFactory } from 'n3';
|
||||
const { namedNode } = DataFactory;
|
||||
|
|
@ -38,24 +39,42 @@ export function useRecords(
|
|||
// ---- //
|
||||
const totalItemCount = ref(0);
|
||||
const isFetchingPage = ref(false);
|
||||
const showScrollTopBtn = ref(false);
|
||||
const showFetchingPageLoader = ref(false)
|
||||
const searchText = ref('');
|
||||
const textMatchType = ref('partial');
|
||||
const instanceItemsComp = ref([]);
|
||||
const newTypeSelected = ref(false);
|
||||
const itemsTrigger = ref(false);
|
||||
const fetchedItemCount = ref(null)
|
||||
const classRecordsLoading = ref(false);
|
||||
const headingHover = ref(false);
|
||||
const orderTopDown = ref(true);
|
||||
const includeSubClasses = ref(false);
|
||||
const includedClasses = ref(null);
|
||||
let hideTimeout = 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 //
|
||||
// --------------------- //
|
||||
onMounted(() => {
|
||||
rdfDS.addEventListener('recordsChanged', (e) => {
|
||||
enqueueChanges(e.detail.records);
|
||||
});
|
||||
});
|
||||
|
||||
watch(isFetchingPage, (newVal) => {
|
||||
if (newVal) {
|
||||
// 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 () => {
|
||||
// If we are using a backend service 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)) {
|
||||
// Only trigger fetch if not already fetching
|
||||
if (!isFetchingPage.value) {
|
||||
await fetchNextPage(searchText.value);
|
||||
await fetchNextPage(selectedIRI.value, searchText.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -112,13 +117,6 @@ export function useRecords(
|
|||
onTypingPause(newVal);
|
||||
}, 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 //
|
||||
|
|
@ -142,63 +140,102 @@ export function useRecords(
|
|||
return 100;
|
||||
}
|
||||
})
|
||||
|
||||
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();
|
||||
if (txt.length == 0) return sortItems(Object.values(recordItemsAll))
|
||||
return sortItems(
|
||||
[...instanceItemsComp.value].filter((item) => {
|
||||
Object.values(recordItemsAll).filter((item) => {
|
||||
if (txt.length == 0) return true;
|
||||
return searchableFields.some((field) => {
|
||||
if (!(field in item.props)) return false;
|
||||
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);
|
||||
}
|
||||
});
|
||||
if (!('_searchBlob' in item.props)) return false;
|
||||
return item.props._searchBlob.includes(txt);
|
||||
})
|
||||
)
|
||||
});
|
||||
|
||||
const matchedInstanceItemsComp = computed(() => {
|
||||
const filteredRecordItemsByClass = computed(() => {
|
||||
let txt = searchText.value.toLowerCase().trim();
|
||||
return sortItems(
|
||||
[...instanceItemsComp.value].filter((item) => {
|
||||
if (txt.length == 0) return true;
|
||||
return searchableFields.some((field) => {
|
||||
if (!(field in item.props)) return false;
|
||||
const value = item.props[field]?.toString().toLowerCase().trim();
|
||||
return value === txt;
|
||||
});
|
||||
})
|
||||
)
|
||||
const map = {};
|
||||
for (const cl of Object.keys(recordItemsByClass)) {
|
||||
map[cl] = sortItems(
|
||||
Object.values(recordItemsByClass[cl]).filter((item) => {
|
||||
if (txt.length == 0) return true;
|
||||
if (!('_searchBlob' in item.props)) return false;
|
||||
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)
|
||||
});
|
||||
|
||||
// --------- //
|
||||
// Functions //
|
||||
// --------- //
|
||||
|
||||
// fetch new items at bottom of scroller
|
||||
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
|
||||
// Continued fetching of more items while there is search text will be handled
|
||||
// by the watcheffect function.
|
||||
if (searchText.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.value.use_service) {
|
||||
if (hasUnfetchedPages(selectedIRI.value) && !isFetchingPage.value) {
|
||||
await fetchNextPage();
|
||||
if (hasUnfetchedPages(classIRI) && !isFetchingPage.value) {
|
||||
await fetchNextPage(classIRI);
|
||||
} else {
|
||||
console.log("Last page already fetched")
|
||||
console.log(`Last page already fetched: ${classIRI}`)
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
|
|
@ -211,7 +248,7 @@ export function useRecords(
|
|||
|
||||
async function onTypingPause(textVal) {
|
||||
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
|
||||
// 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
|
||||
// the watcheffect function.
|
||||
|
||||
async function fetchNextPage(matchText='') {
|
||||
if (isFetchingPage.value || !hasUnfetchedPages(selectedIRI.value, matchText)) return;
|
||||
async function fetchNextPage(classIRI, matchText='') {
|
||||
if (isFetchingPage.value || !hasUnfetchedPages(classIRI, matchText)) return;
|
||||
isFetchingPage.value = true;
|
||||
try {
|
||||
const result = await fetchFromService(
|
||||
'get-paginated-records-constrained',
|
||||
selectedIRI.value,
|
||||
classIRI,
|
||||
allPrefixes,
|
||||
matchText
|
||||
);
|
||||
if (result.status === null) {
|
||||
console.error(result.error);
|
||||
}
|
||||
getInstanceItems(); // rebuild local list of items
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
} 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) {
|
||||
for (const field of searchableFields) {
|
||||
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 //
|
||||
// ------- //
|
||||
|
|
@ -371,14 +417,11 @@ export function useRecords(
|
|||
classRecordsLoading,
|
||||
currentProgress,
|
||||
fetchedItemCount,
|
||||
filteredInstanceItemsComp,
|
||||
getInstanceItems,
|
||||
fetchNextPage,
|
||||
headingHover,
|
||||
includedClasses,
|
||||
includeSubClasses,
|
||||
instanceItemsComp,
|
||||
isFetchingPage,
|
||||
matchedInstanceItemsComp,
|
||||
newTypeSelected,
|
||||
onScrollEnd,
|
||||
onUserTyping,
|
||||
orderTopDown,
|
||||
|
|
@ -388,5 +431,9 @@ export function useRecords(
|
|||
showScrollTopBtn,
|
||||
textMatchType,
|
||||
totalItemCount,
|
||||
recordItemsByClass,
|
||||
filteredRecordItemsAll,
|
||||
filteredRecordItemsByClass,
|
||||
filteredRecordItemsForClassWithSubclassItems,
|
||||
};
|
||||
}
|
||||
|
|
@ -3,9 +3,9 @@
|
|||
* @description This composable reads a ttl file with shacl shapes and returns
|
||||
* 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 { findObjectByKey, toIRI } from '@/modules/utils'
|
||||
import { findObjectByKey, toIRI, toCURIE, getDisplayName, includeClass} from '@/modules/utils'
|
||||
import { SHACL, RDFS} from '@/modules/namespaces';
|
||||
|
||||
const basePath = import.meta.env.BASE_URL || '/';
|
||||
|
|
@ -16,6 +16,14 @@ export function useShapes(config) {
|
|||
// ---- //
|
||||
const defaultURL = `${basePath}dlschemas_shacl.ttl`;
|
||||
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 //
|
||||
|
|
@ -134,6 +142,138 @@ export function useShapes(config) {
|
|||
shapesDS.data.propertyGroups['_default'][RDFS.label.value] = "Additional properties";
|
||||
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 //
|
||||
|
|
@ -144,5 +284,17 @@ export function useShapes(config) {
|
|||
updateShapesFromDefault,
|
||||
updateShapes,
|
||||
updatePropertyGroups,
|
||||
idFilteredNodeShapeNames,
|
||||
noEditClassList,
|
||||
filteredNodeShapeNames,
|
||||
priorityFilteredNodeShapeNames,
|
||||
orderedNodeShapeNames,
|
||||
allClassItems,
|
||||
getIdFilteredNodeShapeNames,
|
||||
getNoEditClassList,
|
||||
getFilteredNodeShapeNames,
|
||||
getPriorityFilteredNodeShapeNames,
|
||||
getOrderedNodeShapeNames,
|
||||
getAllClassItems,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { ref, reactive, toRaw} from "vue";
|
||||
import { fillStringTemplate, findObjectByKey, findObjectIndexByKey, nodeShapeHasProperty} from "@/modules/utils";
|
||||
import { ref, reactive, toRaw } from "vue";
|
||||
import { fillStringTemplate, findObjectByKey, findObjectIndexByKey, nodeShapeHasProperty } from "@/modules/utils";
|
||||
import { toCURIE, toIRI } from "shacl-tulip";
|
||||
import { RDF } from "@/modules/namespaces";
|
||||
import { DataFactory } from 'n3';
|
||||
|
|
@ -8,10 +8,11 @@ import { useNunjucks } from "@/composables/useNunjucks";
|
|||
const { fillNunjucksTemplate } = useNunjucks();
|
||||
|
||||
export function showWizardGroup(configVarsMain, context, classUri, allPrefixes, shapesDS) {
|
||||
console.log("Checking if wizard group should be shown")
|
||||
const classCurie = toCURIE(classUri, allPrefixes);
|
||||
// all classes wizards
|
||||
const all_class_selection = context == '_class' && configVarsMain.wizardEditorSelection?._classes;
|
||||
// class-based wizards ?
|
||||
const selection = configVarsMain.wizardEditorSelection?.[classCurie]?.[context]
|
||||
const selection = configVarsMain.wizardEditorSelection?.[classCurie]?.[context];
|
||||
// slot-based wizards ?
|
||||
let slot_selection = false;
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +53,13 @@ export function useWizard() {
|
|||
let classCurie = toCURIE(class_IRI, allPrefixes)
|
||||
// Load wizard editors if any
|
||||
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]){
|
||||
for (const wizard of configVarsMain.wizardEditorSelection?.[classCurie]?.[context] || []) {
|
||||
wizardsToAdd.add(wizard)
|
||||
|
|
@ -90,6 +97,8 @@ export function useWizard() {
|
|||
async function handleWizardSave(context, class_uri, wizardData, rdfDS, savedNodes, nodesToSubmit, subject_uri=null, formData) {
|
||||
wizardDialog.value = false;
|
||||
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 (context == '_record') {
|
||||
wizardData.pid = subject_uri;
|
||||
|
|
@ -103,12 +112,11 @@ export function useWizard() {
|
|||
}
|
||||
// And then parse TTL, adding quads to graph data
|
||||
let newQuads = await rdfDS.parseTTLandDedup(newTTL);
|
||||
rdfDS.triggerReactivity();
|
||||
// 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 _class or higher level, we can ignore formData because everything happens via template
|
||||
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 a different named node as subject, we need to keep track of it for submission purposes
|
||||
if (q.subject.value == subject_uri) {
|
||||
|
|
@ -140,8 +148,8 @@ export function useWizard() {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
console.log("The context was not _record...")
|
||||
for (const q of newQuads) {
|
||||
rdfDS.emitAddedRecords(newQuads.records)
|
||||
for (const q of newQuads.quads) {
|
||||
// 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.
|
||||
// We need to keep track of the named nodes saved to the graph, for submission
|
||||
|
|
|
|||
|
|
@ -108,6 +108,14 @@ export function addCodeTagsToText(text, prepend, append) {
|
|||
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) + "…";
|
||||
};
|
||||
|
||||
export function findObjectByKey(array, key, value) {
|
||||
return array.find((obj) => obj[key] === value);
|
||||
}
|
||||
|
|
@ -621,6 +629,33 @@ export function collectBlankNodeHierarchy(store, rootBNode) {
|
|||
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) {
|
||||
// Return an array of quads related to a specific named node
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue