New wizard editor component #320

Merged
jsheunis merged 1 commit from wizardeditor into main 2026-02-04 17:42:20 +00:00
8 changed files with 507 additions and 34 deletions

View file

@ -9,4 +9,5 @@ layout: doc
1. [Backend integration](./features-dumpthings)
2. [Forgejo integration](./features-forgejo)
3. [Config-driven editor matching](./features-editor-matching)
4. [File upload](./features-file-upload)
4. [File upload](./features-file-upload)
5. [Wizard editors](./features-wizard-editors)

View file

@ -0,0 +1,134 @@
---
layout: doc
---
# Wizard editors
Sometimes the standard `shacl-vue` approach of matching a dedicated editor component to a single modeled property shape does not fully address the use case at hand. Think of creating complicated, multi-jump linkages between a record being edited and some other records, perhaps multiple at a time.
Let's take the following example: a user might want to add a DOI (Digital Object Identifier) to a `Publication` record, but there is no `doi` property on the `Publication` class, and instead:
- a `Publication` has an `identifiers` slot, with the range `Identifier`
- the `Identifier` class has slots: `notation` and `creator`
- there is also a `DOI` class that subclasses from `Identifier`
- to create a `DOI` record, the `notation` is important while the `creator` is already known
Ideally, all of these classes and slots and linkages are details that should be of no concern to the user. They just want to add a DOI for their publication (actually the only really required field to enter is `notation`!) and having to do that via the existing route (find the `identifiers` slot -> create a new `DOI` record -> add `notation` -> add the `creator` correctly! -> finally save and link the created `DOI` record) is unnecessarily complex and bad UX.
There should be simpler way to achieve this. And there is, using the "Wizard Editor"!
This is a powerful feature that allows specification of arbitrarily complex user input and metadata linkage via relatively simple configuration of primitive form inputs and metadata templates. Think of it as a way to:
- specify which arbitrary inputs should be shown in a form
- specify how those input values should feed into a TTL template
- autocreate arbitrarily complex and linked metadata upon saving the wizard form
A wizard editor can be configured to appear for any class, to use any of the supported inputs, and to use any TTL template. Specific configuration options are provided below.
## Wizard configuration
This section will provide all currently available wizard configuration options using the DOI use case as a demonstrator, with the following config:
```yaml
wizard_editor_selection:
xyzri:XYZPublication:
- DOIWizard
wizard_editors:
DOIWizard:
name: DOI Wizard
description: Enter the 'Notation' field and hit *Save* in order to create a DOI record
icon: mdi-identifier
tooltip: Add a DOI
inputs:
- prop: notation
name: Notation
description: This is the unique DOI identifier text usually following 'http://doi.org/'
type: text
placeholder: 'example: 10.21105/joss.03262'
required: true
pattern: # some XSD-compatible regular expression
default: # some default value
template: content:DOIWizardTemplate
content:
DOIWizardTemplate:
url: DOIWizardTemplate.ttl
```
### `wizard_editor_selection`
This option provides a means to specify which wizards should show up for which classes. It is an object with class IRIs as keys (in CURIE format) and associated values being lists of wizards to make available for the class identied by the key.
### `wizard_editors`
This is the main config option that allows creation of a wizard editor and specification of its `input`s, `template`, and other options. The wizard receives a key (here `DOIWizard`), and then its options:
Wizard options include:
#### `name`
This is a human-readable name for the wizard that will be displayed at the top of the wizard form.
#### `description`
This is human-readble descriptive text or instructions to help users understand the context and purpose of the form, and will render inside the initiated wizard form underneath the name.
#### `icon`
This provides a means to specify the icon image that will show up on records for which the specific wizard has been configured to render. Clicking on the icon is the primary and only way to open the associated wizard editor. Icons can be specified in different formats:
- an `mdi-` string which will render any of the standard free icons from the [`mdi` icon library](https://pictogrammers.com/library/mdi/)
- an svg string, which will be rendered as is
- a `content:` pointer to an SVG file, which will be loaded and rendered as is
Importantly, any SVGf file or string used in this context should have a viewport or width/height of `24x24 px`.
#### `tooltip`
This is a short human-readable sentence that will show when the cursor hovers over the wizard icon and is intended to explain shortly what the wizard does.
#### `input`s
The core of the wizard lies in its specification of `input`s. An `input` can have several properties:
- `prop` is the field name through which this input is referenced, both in `shacl-vue` internals and inside the TTL template
- `name` will render as the input label
- `description` is a further description of the input that will render when hovering over the input label
- `type` tells `shacl-vue` which type of input component to render, for which the input value will be captured; currently only `text`, `text-paragraph` and `boolean` are supported, and many more are in the pipeline
- `placeholder` will render if the field has no entered value yet
- `required` will make the field required (or not) for validation purposes
- `pattern` will apply a regular expression check to text-based inputs for validation purposes
- `default` will prepulate the value of the input
#### `template`
Another core part of the wizard specification is the TTL template string, which will be populated using the values entered for the wizard inputs. The TTL string format is the same as is already supported by `shacl-vue`. For example:
```ttl
@prefix dcterms: <http://purl.org/dc/terms/> .
@prefix dlthings: <https://concepts.datalad.org/s/things/unreleased/> .
@prefix ror: <https://ror.org/> .
@prefix skos: <http://www.w3.org/2004/02/skos/core#> .
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
<{pid}> dcterms:identifier _:n0-1.
_:n0-1 a dlthings:DOI;
skos:notation "{notation}";
dcterms:creator ror:01fyxcz70.
```
Note the following inside the template:
- `{pid}` is the standard way to refer to the record within the context of which the wizard editor is rendered, for example if the `DOIWizard` is initiated from a specific `Publication` record, that record's identifier becomes available inside the TTL template.
- `{notation}` is an example of how the `prop` of a configured `input` is used to reference it inside the TTL template
The TTL template can be specified as a direct string inside the configuration, but also as a file via a `content:` pointer to the file URL.
Finally, another new config option `wizard_editor_selection` can be used to specify which wizards should be made available to which classes. The config above says: show the `DOI Wizard` for `xyzri:XYZPublication` records (during creation/editing).
## Wizard editor demo
::: info
TODO
:::

View file

@ -1,13 +1,34 @@
<template>
<span v-if="ready">
<span v-if="showWizards">
<v-row no-gutters align="center">
<v-col cols="4">
<v-icon>mdi-wizard-hat</v-icon> Wizards:
</v-col>
<v-col>
<v-tooltip v-for="wizE in Object.keys(wizardEditors)" :text="wizardEditors[wizE].tooltip" location="top">
<template v-slot:activator="{ props }">
<span
v-bind="props"
@click="openWizard(wizardEditors[wizE])"
style="margin-left: 0.5em; cursor: pointer;"
>
<span v-if="wizardEditors[wizE].iconFig.type == 'mdi'">
<v-icon>{{ wizardEditors[wizE].iconFig.icon }}</v-icon>
</span>
<span v-else>
<SVGIcon :icon="wizardEditors[wizE].iconFig.icon"></SVGIcon>
</span>
</span>
</template>
</v-tooltip>
<v-dialog v-model="wizardDialog" max-width="800px">
<WizardEditor :wizardConfig="selectedWizard" @save="handleWizardSave" @cancel="handleWizardCancel"></WizardEditor>
</v-dialog>
</v-col>
</v-row>
</span>
<span v-if="classProperties[localShapeIri].length > 0">
<h3>
Properties from:
<code class="code-style">{{
getDisplayName(localShapeIri, configVarsMain, allPrefixes, shape_obj)
}}</code>
</h3>
<br />
<span
v-for="property in classProperties[localShapeIri]"
:key="localShapeIri + '-' + localNodeIdx + '-' + property"
@ -23,13 +44,6 @@
<span v-for="c in superClasses[localShapeIri]">
<span v-if="groupHasVisibleProps(c)">
<h3>
Properties from:
<code class="code-style">{{
getDisplayName(c, configVarsMain, allPrefixes, shapesDS.data.nodeShapes[c])
}}</code>
</h3>
<br />
<span
v-for="property in classProperties[c]"
:key="
@ -53,9 +67,18 @@
</template>
<script setup>
import { ref, onBeforeUnmount, onMounted, inject, toRaw } from 'vue';
import { SHACL, RDF, RDFS, DLCO } from '../modules/namespaces';
import { getDisplayName, objectsEqual, nameOrCURIE} from '../modules/utils';
import { ref, onBeforeUnmount, onMounted, inject, toRaw, reactive} from 'vue';
import { SHACL, RDF, DLCO } from '../modules/namespaces';
import {
objectsEqual,
nameOrCURIE,
getContent,
fillStringTemplate,
findObjectByKey,
findObjectIndexByKey,
} from '../modules/utils';
import { toCURIE } from 'shacl-tulip';
import SVGIcon from '@/components/SVGIcon.vue'
// ----- //
// Props //
@ -73,15 +96,40 @@ const props = defineProps({
const localShapeIri = ref(props.shape_iri);
const localNodeIdx = ref(props.node_idx);
const shapesDS = inject('shapesDS');
const rdfDS = inject('rdfDS');
const formData = inject('formData');
const superClasses = inject('superClasses');
const allPrefixes = inject('allPrefixes');
const configVarsMain = inject('configVarsMain');
const show_all_fields = inject('show_all_fields');
const savedNodes = inject('savedNodes');
const nodesToSubmit = inject('nodesToSubmit');
const registerHandler = inject('registerHandler')
const shape_obj = shapesDS.data.nodeShapes[localShapeIri.value];
const ready = ref(false);
const ignoredProperties = [RDF.type.value];
var propertyShapes = {};
var classProperties;
const showWizards = ref(false)
const wizardEditors = reactive({});
const wizardDialog = ref(false);
const selectedWizard = ref(null);
const wizardAddedQuads = ref([]);
function onFormCancel() {
console.log("Running onFormCancel from NodeShapeEditor")
for (const q of wizardAddedQuads.value) {
// remove named nodes from savedNodes and nodesToSubmit
if (q.subject.termType == 'NamedNode' && q.predicate.value == RDF.type.value) {
console.log(`Removing named node from savedNodes and nodesToSubmit: ${q.subject.value}`)
savedNodes.value.splice(findObjectIndexByKey(savedNodes.value, 'node_iri', q.subject.value), 1)
nodesToSubmit.value.splice(findObjectIndexByKey(nodesToSubmit.value, 'node_iri', q.subject.value), 1)
}
// remove quad from graph store
console.log(`Removing quad from graph store: ${q.subject.value} - ${q.predicate.value} - ${q.object.value}`)
rdfDS.data.graph.delete(q)
}
}
registerHandler('cancel', onFormCancel)
// ----------------- //
// Lifecycle methods //
@ -92,7 +140,19 @@ onMounted(() => {
propertyShapes[p[SHACL.path.value]] = p;
}
classProperties = orderProperties(propertyShapes);
console.log(classProperties);
console.log(localShapeIri.value)
let classCurie = toCURIE(localShapeIri.value, allPrefixes)
// Load wizard editors if any, also load template content
if (Object.keys(configVarsMain.wizardEditorSelection) && Object.keys(configVarsMain.wizardEditorSelection).includes(classCurie)){
for (const wizard of configVarsMain.wizardEditorSelection[classCurie]) {
wizardEditors[wizard] = configVarsMain.wizardEditors[wizard]
wizardEditors[wizard].template = getContent(configVarsMain.content, wizardEditors[wizard].template)
wizardEditors[wizard].iconFig = getIcon(wizardEditors[wizard])
}
}
if (Object.keys(wizardEditors).length > 0) {
showWizards.value = true
}
ready.value = true;
});
@ -105,6 +165,97 @@ onBeforeUnmount(() => {
// Functions //
// --------- //
function getIcon(wizard) {
if (wizard.icon) {
if (wizard.icon.startsWith('mdi-')) {
return {
type: 'mdi',
icon: wizard.icon
}
} else if (wizard.icon.startsWith('content:')) {
return {
type: 'svg',
icon: getContent(configVarsMain.content, wizard.icon)
}
} else {
return {
type: 'svg',
icon: wizard.icon
}
}
}
return {
type: 'mdi',
icon: 'mdi-plus-box'
}
}
function openWizard(wizard) {
selectedWizard.value = wizard;
wizardDialog.value = true;
}
async function handleWizardSave(wizardData) {
// vars
let class_uri = localShapeIri.value;
let subject_uri = localNodeIdx.value;
wizardDialog.value = false;
selectedWizard.value = null;
// Add current formData node ID as "pid"
wizardData.pid = subject_uri;
// fill string template
let newTTL = fillStringTemplate(wizardData._template, wizardData)
// parse TTL, adding quads to graph data
let newQuads = await rdfDS.parseTTLandDedup(newTTL);
rdfDS.triggerReactivity();
// Now we process each added quad
for (const q of newQuads) {
// 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) {
// Skip unlikely but possible redeclaration of the current record
if (q.predicate.value != RDF.type.value) {
// Do not add an object to the predicate array if the exact value already exists there
let mustAddObject = true;
let objectArray = formData.content[class_uri][subject_uri][q.predicate.value]
if (objectArray) {
const existingObjectVal = objectArray.find((element) => element.value === q.object.value);
if (existingObjectVal) mustAddObject = false;
}
if (mustAddObject) {
// we use formData.addPredicate because formData.addObject assumes the predicate already has at least one value in the array
formData.addPredicate(class_uri, subject_uri, q.predicate.value)
let newLength = formData.content[class_uri][subject_uri][q.predicate.value].length
formData.content[class_uri][subject_uri][q.predicate.value][newLength-1].value = q.object.value;
formData.content[class_uri][subject_uri][q.predicate.value][newLength-1]._key = crypto.randomUUID();
}
}
// now we remove the record quad from graph because it was added prematurely;
// this will be re-added, (importantly: with the correct PID), when the main form is saved
rdfDS.data.graph.delete(q)
} else {
// We keep track of all quads added to the graph, in case they need to be removed on form cancel
wizardAddedQuads.value.push(q);
// We need to keep track of the named nodes saved to the graph, for submission
if (q.subject.termType == 'NamedNode' && q.predicate.value == RDF.type.value) {
let saved_node = {
nodeshape_iri: q.object.value,
node_iri: q.subject.value
}
savedNodes.value.push(saved_node);
if (!findObjectByKey(nodesToSubmit.value, 'node_iri', saved_node.node_iri)) {
nodesToSubmit.value.push(saved_node);
}
}
}
}
}
function handleWizardCancel() {
wizardDialog.value = false;
selectedWizard.value = null;
}
function orderProperties(propertyShapes) {
// The current class has a possible hierarchy of superclasses
// The current class has properties, any of which can originate from

View file

@ -0,0 +1,26 @@
<template>
<v-icon :size="size">
<span
class="custom-svg"
v-html="icon"
/>
</v-icon>
</template>
<script setup>
defineProps({
icon: String,
size: {
type: [Number, String],
default: 24,
},
})
</script>
<style scoped>
.custom-svg svg {
width: 100%;
height: 100%;
fill: currentColor;
}
</style>

View file

@ -0,0 +1,159 @@
<template>
<v-card class="pa-1" v-if="props.wizardConfig">
<v-card-title>{{ props.wizardConfig.name }}</v-card-title>
<v-card-text>
{{ props.wizardConfig.description }}
</v-card-text>
<v-card-text>
<v-form
ref="wizardForm"
v-model="wizardFormValid"
validate-on="lazy input"
@submit.prevent="saveForm()"
>
<v-row no-gutters align="center" v-for="input in props.wizardConfig.inputs" :key="input.prop">
<v-col cols="4">
<span v-if="input.description">
<v-tooltip :text="input.description" location="end top" origin="start bottom">
<template v-slot:activator="{ props }">
<span v-bind="props">{{ input.name }}</span>
</template>
</v-tooltip>
</span>
<span v-else>
{{ input.name }}
</span>
</v-col>
<v-col>
<span >
<span v-if="input.type == 'text'">
<v-text-field v-model="modelVals[input.prop]" density="compact" variant="outlined" :label="input.placeholder ? input.placeholder : 'add text'" hide-details="auto" :rules="rules[input.prop]"></v-text-field>
</span>
<span v-else-if="input.type == 'text-paragraph'">
<v-textarea v-model="modelVals[input.prop]" density="compact" variant="outlined" :label="input.placeholder ? input.placeholder : 'add text'" hide-details="auto" :rules="rules[input.prop]"></v-textarea>
</span>
<span v-else-if="input.type == 'boolean'">
<v-switch v-model="modelVals[input.prop]" density="compact" variant="outlined" :label="input.placeholder ? input.placeholder : 'select value'" inset hide-details="auto" :rules="rules[input.prop]"></v-switch>
</span>
<span v-else>
kaaaaaaaaa
</span>
</span>
</v-col>
</v-row>
</v-form>
</v-card-text>
<v-card-actions>
<v-btn
text="Cancel"
@click="cancelForm()"
style="margin-left: auto; margin-right: 1em"
prepend-icon="mdi-close-box"
></v-btn>
<v-btn
text="Reset"
@click="resetForm()"
style="margin-right: 1em"
prepend-icon="mdi-undo"
></v-btn>
<v-btn
text="Save"
type="submit"
@click="saveForm()"
prepend-icon="mdi-content-save"
></v-btn>
</v-card-actions>
</v-card>
</template>
<script setup>
import { reactive, ref, toRaw, watch} from 'vue';
// Define component props
const props = defineProps({
wizardConfig: Object,
});
const emit = defineEmits(['save', 'cancel'])
// Refs
const wizardForm = ref(null);
const wizardFormValid = ref(null);
const modelVals = reactive({})
const rules = reactive({});
const baseRules = {}
watch(
() => props.wizardConfig,
(config) => initForm(config),
{ immediate: true }
)
function cancelForm() {
emit('cancel')
}
function initForm(config) {
Object.keys(modelVals).forEach(k => delete modelVals[k])
Object.keys(rules).forEach(k => delete rules[k])
if (!config?.inputs) return
for (const input of config.inputs) {
// Initialize model value per input
modelVals[input.prop] = null
// Set up validation rules
rules[input.prop] = []
// Add base rule if it exists
if (baseRules[input.type]) {
rules[input.prop].push(baseRules[input.type])
}
// Add required rule
if (input.required) {
rules[input.prop].push((value) => {
if (value) return true;
return 'This is a required field';
});
}
// Add pattern matching rule
if (input.pattern) {
const {jsFlags, jsPattern} = getJsRegex(input.pattern)
// anchor so it must match the entire value
let anchored = jsPattern;
if (!(jsPattern.startsWith('^') && jsPattern.endsWith('$'))) {
anchored = `^${jsPattern}$`;
}
let regex;
try {
regex = new RegExp(anchored, jsFlags);
const message = input.message ? input.message : 'Input does not match the required format';
rules[input.prop].push((v) => {
if (!v) return true;
return regex.test(v) || message;
});
} catch (err) {
console.error(`Invalid pattern “${input.pattern}”:`, err);
}
}
}
}
// Functions
async function saveForm() {
try {
// Await the validation result
const validationResult = await wizardForm.value.validate();
if (validationResult.valid) {
// If the form is valid, proceed to emit event with data
emit('save', {...toRaw(modelVals), _template: toRaw(props.wizardConfig.template)})
} else {
console.log('Still some wizard form validation errors...');
validationResult.errors.forEach((error) => {
console.log(error)
});
}
} catch (error) {
console.error('Wizard form validation failed:', error);
}
}
function resetForm() {
Object.keys(modelVals).forEach(k => modelVals[k] = null)
}
</script>

View file

@ -93,6 +93,8 @@ const mainVarsToLoad = {
gitannex_p2phttp_config: {},
update_shapes: {},
update_shapes_default: {},
wizard_editors: {},
wizard_editor_selection: {},
};
function mergeCustomizer(objValue, srcValue) {

View file

@ -1,21 +1,7 @@
// rules.js
import { ref } from 'vue';
import { SHACL } from '../modules/namespaces';
const XSD_FLAGS_PATTERN = /^\(\?([imsx]+)\)\^/;
function getJsRegex(xsdPattern) {
let jsFlags = '';
let jsPattern = xsdPattern;
// Check of the pattern string includes flags, e.g.:`(?i)^`
const m = xsdPattern.match(XSD_FLAGS_PATTERN);
if (m) {
// Only keep JS-compatible flags: i, m, s
jsFlags = [...m[1]].filter(f => 'ims'.includes(f)).join('');
jsPattern = xsdPattern.replace(/^\(\?[imsx]+\)/, '');
}
return {jsFlags, jsPattern};
}
import { getJsRegex } from '../modules/utils'
export function useRules(propShape) {
const isRequired = ref(false);

View file

@ -800,4 +800,18 @@ export function getNotes(shape) {
}
}
return null
}
const XSD_FLAGS_PATTERN = /^\(\?([imsx]+)\)\^/;
export function getJsRegex(xsdPattern) {
let jsFlags = '';
let jsPattern = xsdPattern;
// Check of the pattern string includes flags, e.g.:`(?i)^`
const m = xsdPattern.match(XSD_FLAGS_PATTERN);
if (m) {
// Only keep JS-compatible flags: i, m, s
jsFlags = [...m[1]].filter(f => 'ims'.includes(f)).join('');
jsPattern = xsdPattern.replace(/^\(\?[imsx]+\)/, '');
}
return {jsFlags, jsPattern};
}