@ulb-darmstadt/shacl-form
Version:
SHACL form generator
291 lines (269 loc) • 14.9 kB
text/typescript
import { ShaclNode } from './node'
import { Config } from './config'
import { ClassInstanceProvider, Plugin, listPlugins, registerPlugin } from './plugin'
import { Store, NamedNode, DataFactory } from 'n3'
import { DCTERMS_PREDICATE_CONFORMS_TO, RDF_PREDICATE_TYPE, SHACL_OBJECT_NODE_SHAPE, SHACL_PREDICATE_TARGET_CLASS } from './constants'
import { Editor, Theme } from './theme'
import { serialize } from './serialize'
import { Validator } from 'shacl-engine'
import { setSharedShapesGraph } from './loader'
export class ShaclForm extends HTMLElement {
static get observedAttributes() { return Config.dataAttributes() }
config: Config
shape: ShaclNode | null = null
form: HTMLFormElement
initDebounceTimeout: ReturnType<typeof setTimeout> | undefined
constructor(theme: Theme) {
super()
this.attachShadow({ mode: 'open' })
this.form = document.createElement('form')
this.config = new Config(theme, this.form)
this.form.addEventListener('change', ev => {
ev.stopPropagation()
if (this.config.editMode) {
this.validate(true).then(valid => {
this.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: false, composed: true, detail: { 'valid': valid } }))
}).catch(e => { console.warn(e) })
}
})
}
connectedCallback() {
this.shadowRoot!.prepend(this.form)
}
attributeChangedCallback() {
this.config.updateAttributes(this)
this.initialize()
}
private initialize() {
clearTimeout(this.initDebounceTimeout)
this.initDebounceTimeout = setTimeout(async () => {
// remove all child elements from form and show loading indicator
this.form.replaceChildren(document.createTextNode(this.config.attributes.loading))
try {
await this.config.loader.loadGraphs()
// remove loading indicator
this.form.replaceChildren()
// reset rendered node references
this.config.renderedNodes.clear()
// find root shacl shape
const rootShapeShaclSubject = this.findRootShaclShapeSubject()
if (rootShapeShaclSubject) {
// remove all previous css classes to have a defined state
this.form.classList.forEach(value => { this.form.classList.remove(value) })
this.form.classList.toggle('mode-edit', this.config.editMode)
this.form.classList.toggle('mode-view', !this.config.editMode)
// let theme add classes to form element
this.config.theme.apply(this.form)
// adopt stylesheets from theme and plugins
const styles: CSSStyleSheet[] = [ this.config.theme.stylesheet ]
for (const plugin of listPlugins()) {
if (plugin.stylesheet) {
styles.push(plugin.stylesheet)
}
}
this.shadowRoot!.adoptedStyleSheets = styles
this.shape = new ShaclNode(rootShapeShaclSubject, this.config, this.config.attributes.valuesSubject ? DataFactory.namedNode(this.config.attributes.valuesSubject) : undefined)
this.form.appendChild(this.shape)
if (this.config.editMode) {
// add submit button
if (this.config.attributes.submitButton !== null) {
const button = this.config.theme.createButton(this.config.attributes.submitButton || 'Submit', true)
button.addEventListener('click', (event) => {
event.preventDefault()
// let browser check form validity first
if (this.form.reportValidity()) {
// now validate data graph
this.validate().then(valid => {
if (valid) {
// form and data graph are valid, so fire submit event
this.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
} else {
// focus first invalid element
(this.form.querySelector(':scope .invalid > .editor') as HTMLElement)?.focus()
}
})
}
})
this.form.appendChild(button)
}
await this.validate(true)
}
} else if (this.config.shapesGraph.size > 0) {
// raise error only when shapes graph is not empty
throw new Error('shacl root node shape not found')
}
} catch (e) {
console.error(e)
const errorDisplay = document.createElement('div')
errorDisplay.innerText = String(e)
this.form.replaceChildren(errorDisplay)
}
}, 200)
}
public serialize(format = 'text/turtle', graph = this.toRDF()): string {
const quads = graph.getQuads(null, null, null, null)
return serialize(quads, format, this.config.prefixes)
}
public toRDF(graph = new Store()): Store {
this.shape?.toRDF(graph)
return graph
}
public registerPlugin(plugin: Plugin) {
registerPlugin(plugin)
this.initialize()
}
public setTheme(theme: Theme) {
this.config.theme = theme
this.initialize()
}
public setSharedShapesGraph(graph: Store) {
setSharedShapesGraph(graph)
this.initialize()
}
public setClassInstanceProvider(provider: ClassInstanceProvider) {
this.config.classInstanceProvider = provider
this.initialize()
}
public async validate(ignoreEmptyValues = false): Promise<boolean> {
for (const elem of this.form.querySelectorAll(':scope .validation-error')) {
elem.remove()
}
for (const elem of this.form.querySelectorAll(':scope .property-instance')) {
elem.classList.remove('invalid')
if (((elem.querySelector(':scope > .editor')) as Editor)?.value) {
elem.classList.add('valid')
} else {
elem.classList.remove('valid')
}
}
this.config.shapesGraph.deleteGraph('')
this.shape?.toRDF(this.config.shapesGraph)
try {
const dataset = this.config.shapesGraph
const report = await new Validator(dataset, { details: true, factory: DataFactory }).validate({ dataset })
for (const result of report.results) {
if (result.focusNode?.ptrs?.length) {
for (const ptr of result.focusNode.ptrs) {
const focusNode = ptr._term
// result.path can be empty, e.g. if a focus node does not contain a required property node
if (result.path?.length) {
const path = result.path[0].predicates[0]
// try to find most specific editor elements first
let invalidElements = this.form.querySelectorAll(`:scope [data-node-id='${focusNode.id}'] [data-path='${path.id}'] > .editor`)
if (invalidElements.length === 0) {
// if no editors found, select respective node. this will be the case for node shape violations.
invalidElements = this.form.querySelectorAll(`:scope [data-node-id='${focusNode.id}'] [data-path='${path.id}']`)
}
for (const invalidElement of invalidElements) {
if (invalidElement.classList.contains('editor')) {
// this is a property shape violation
if (!ignoreEmptyValues || (invalidElement as Editor).value) {
let parent: HTMLElement | null = invalidElement.parentElement!
parent.classList.add('invalid')
parent.classList.remove('valid')
parent.appendChild(this.createValidationErrorDisplay(result))
do {
if (parent.classList.contains('collapsible')) {
parent.classList.add('open')
}
parent = parent.parentElement
} while (parent)
}
} else if (!ignoreEmptyValues) {
// this is a node shape violation
invalidElement.classList.add('invalid')
invalidElement.classList.remove('valid')
invalidElement.appendChild(this.createValidationErrorDisplay(result, 'node'))
}
}
} else if (!ignoreEmptyValues) {
this.form.querySelector(`:scope [data-node-id='${focusNode.id}']`)?.prepend(this.createValidationErrorDisplay(result, 'node'))
}
}
}
}
return report.conforms
} catch(e) {
console.error(e)
return false
}
}
private createValidationErrorDisplay(validatonResult?: any, clazz?: string): HTMLElement {
const messageElement = document.createElement('span')
messageElement.classList.add('validation-error')
if (clazz) {
messageElement.classList.add(clazz)
}
if (validatonResult) {
if (validatonResult.message?.length > 0) {
for (const message of validatonResult.message) {
messageElement.title += message.value + '\n'
}
} else {
messageElement.title = validatonResult.sourceConstraintComponent?.value
}
}
return messageElement
}
private findRootShaclShapeSubject(): NamedNode | undefined {
let rootShapeShaclSubject: NamedNode | null = null
// if data-shape-subject is set, use that
if (this.config.attributes.shapeSubject) {
rootShapeShaclSubject = DataFactory.namedNode(this.config.attributes.shapeSubject)
if (this.config.shapesGraph.getQuads(rootShapeShaclSubject, RDF_PREDICATE_TYPE, SHACL_OBJECT_NODE_SHAPE, null).length === 0) {
console.warn(`shapes graph does not contain requested root shape ${this.config.attributes.shapeSubject}`)
return
}
}
else {
// if we have a data graph and data-values-subject is set, use shape of that
if (this.config.attributes.valuesSubject && this.config.dataGraph.size > 0) {
const rootValueSubject = DataFactory.namedNode(this.config.attributes.valuesSubject)
const rootValueSubjectTypes = [
...this.config.dataGraph.getQuads(rootValueSubject, RDF_PREDICATE_TYPE, null, null),
...this.config.dataGraph.getQuads(rootValueSubject, DCTERMS_PREDICATE_CONFORMS_TO, null, null)
]
if (rootValueSubjectTypes.length === 0) {
console.warn(`value subject '${this.config.attributes.valuesSubject}' has neither ${RDF_PREDICATE_TYPE.id} nor ${DCTERMS_PREDICATE_CONFORMS_TO.id} statement`)
return
}
// if type/conformsTo refers to a node shape, prioritize that over targetClass resolution
for (const rootValueSubjectType of rootValueSubjectTypes) {
if (this.config.shapesGraph.getQuads(rootValueSubjectType.object as NamedNode, RDF_PREDICATE_TYPE, SHACL_OBJECT_NODE_SHAPE, null).length > 0) {
rootShapeShaclSubject = rootValueSubjectType.object as NamedNode
break
}
}
if (!rootShapeShaclSubject) {
const rootShapes = this.config.shapesGraph.getQuads(null, SHACL_PREDICATE_TARGET_CLASS, rootValueSubjectTypes[0].object, null)
if (rootShapes.length === 0) {
console.error(`value subject '${this.config.attributes.valuesSubject}' has no shacl shape definition in the shapes graph`)
return
}
if (rootShapes.length > 1) {
console.warn(`value subject '${this.config.attributes.valuesSubject}' has multiple shacl shape definitions in the shapes graph, choosing the first found (${rootShapes[0].subject})`)
}
if (this.config.shapesGraph.getQuads(rootShapes[0].subject, RDF_PREDICATE_TYPE, SHACL_OBJECT_NODE_SHAPE, null).length === 0) {
console.error(`value subject '${this.config.attributes.valuesSubject}' references a shape which is not a NodeShape (${rootShapes[0].subject})`)
return
}
rootShapeShaclSubject = rootShapes[0].subject as NamedNode
}
}
else {
// choose first of all defined root shapes
const rootShapes = this.config.shapesGraph.getQuads(null, RDF_PREDICATE_TYPE, SHACL_OBJECT_NODE_SHAPE, null)
if (rootShapes.length == 0) {
console.warn('shapes graph does not contain any root shapes')
return
}
if (rootShapes.length > 1) {
console.warn('shapes graph contains', rootShapes.length, 'root shapes. choosing first found which is', rootShapes[0].subject.value)
console.info('hint: set the shape to use with attribute "data-shape-subject"')
}
rootShapeShaclSubject = rootShapes[0].subject as NamedNode
}
}
return rootShapeShaclSubject
}
}