UNPKG

@ulb-darmstadt/shacl-form

Version:
291 lines (269 loc) 14.9 kB
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 } }