diff --git a/app/controllers/index.js b/app/controllers/index.js index 575c6eb..e6b84e7 100644 --- a/app/controllers/index.js +++ b/app/controllers/index.js @@ -1,9 +1,15 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; -import { inject as service } from '@ember/service'; import { tracked } from '@glimmer/tracking'; -import { A } from '@ember/array'; -import { namedNode } from 'rdflib'; +import { v4 as uuid } from 'uuid'; +import { + fetch, + getDefaultSession, + handleIncomingRedirect, + login, + logout, +} from '@smessie/solid-client-authn-browser'; +import { service } from '@ember/service'; import { n3reasoner } from 'eyereasoner'; export default class IndexController extends Controller { @@ -11,85 +17,10 @@ export default class IndexController extends Controller { @tracked form = null; - @service store; @service solidAuth; - @tracked isRdfFormVocabulary = - this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#'; - @tracked isSolidUiVocabulary = - this.model.vocabulary === 'http://www.w3.org/ns/ui#'; - @tracked isShaclVocabulary = - this.model.vocabulary === 'http://www.w3.org/ns/shacl#'; - - @tracked success = null; - @tracked error = null; - - @tracked - fields = this.loadFields(); - - @tracked policyTypeError = ''; - @tracked policyURLError = ''; - @tracked policyMethodError = ''; - @tracked policyContentTypeError = ''; - - loadFields() { - let fields; - if ( - this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' - ) { - fields = A(this.store.all('rdf-form-field').sortBy('order')); - fields.forEach((field) => { - field.isSelect = field.widget === 'dropdown'; - field.canHavePlaceholder = - field.rdfType.value === - 'http://rdf.danielbeeke.nl/form/form-dev.ttl#Field' && - (field.widget === 'string' || field.widget === 'textarea'); - }); - } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { - fields = A([ - ...this.store.all('ui-form-field', { - rdfType: namedNode('http://www.w3.org/ns/ui#SingleLineTextField'), - }), - ...this.store.all('ui-form-field', { - rdfType: namedNode('http://www.w3.org/ns/ui#MultiLineTextField'), - }), - ...this.store.all('ui-form-field', { - rdfType: namedNode('http://www.w3.org/ns/ui#Choice'), - }), - ...this.store.all('ui-form-field', { - rdfType: namedNode('http://www.w3.org/ns/ui#DateField'), - }), - ...this.store.all('ui-form-field', { - rdfType: namedNode('http://www.w3.org/ns/ui#BooleanField'), - }), - ]).sortBy('order'); - fields.forEach((field) => { - field.widget = this.getWidgetTypeFromSolidUiRdfType(field.rdfType); - field.isSelect = - field.rdfType.value === 'http://www.w3.org/ns/ui#Choice'; - field.options = this.store.all('ui-form-option', { - rdfType: field.choice?.uri, - }); - field.options.forEach((option) => { - option.binding = option.uri; - }); - }); - } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { - fields = A(this.store.all('shacl-form-field').sortBy('order')); - fields.forEach((field) => { - field.widget = this.getWidgetTypeFromShaclDatatypeOrNodeKind( - field.datatype, - field.nodeKind - ); - field.isSelect = - field.nodeKind?.value === 'http://www.w3.org/ns/shacl#IRI'; - field.options.forEach((option) => { - option.binding = option.uri; - }); - }); - } - return fields; - } + @tracked authError; + @tracked oidcIssuer = ''; /** * Used in the template. @@ -100,53 +31,65 @@ export default class IndexController extends Controller { @action addFormElement(type) { - if (type) { + if (type && !type.startsWith('policy-')) { + // Get base uri from form uri + const baseUri = this.model.loadedFormUri.split('#')[0]; + let field = { + uuid: uuid(), + uri: `${baseUri}#${uuid()}`, + widget: type, + label: '', + property: '', + order: this.model.fields.length, + isSelect: type === 'dropdown', + options: [], + }; + if ( this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' ) { - const field = this.store.create('rdf-form-field', { - widget: type, - }); - field.isSelect = type === 'dropdown'; - field.canHavePlaceholder = type === 'string' || type === 'textarea'; - this.fields = [...this.fields, field]; + field = { + ...field, + ...{ + type: type, + required: false, + multiple: false, + canHavePlaceholder: type === 'string' || type === 'textarea', + placeholder: '', + canHaveChoiceBinding: false, + }, + }; } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { - const field = this.store.create('ui-form-field', {}); - if (type !== 'string') { - field.setRdfType(this.getSolidUiRdfTypeFromWidgetType(type)); - } - field.widget = type; - field.isSelect = type === 'dropdown'; - if (field.isSelect) { - field.choice = this.store.create('ui-form-choice', { - comment: 'Choice object for a dropdown form field', - }); - } - this.fields = [...this.fields, field]; + field = { + ...field, + ...{ + type: this.getSolidUiRdfTypeFromWidgetType(type), + required: false, + multiple: false, + listSubject: `${baseUri}#${uuid()}`, + canHavePlaceholder: false, + canHaveChoiceBinding: true, + }, + ...(type === 'dropdown' + ? { + choice: `${baseUri}#${uuid()}`, + } + : {}), + }; } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { - let options = {}; - if (type === 'dropdown') { - options = { - nodeKind: namedNode('http://www.w3.org/ns/shacl#IRI'), - }; - } else if (type === 'string') { - options = { - datatype: namedNode('http://www.w3.org/2001/XMLSchema#string'), - }; - } else if (type === 'date') { - options = { - datatype: namedNode('http://www.w3.org/2001/XMLSchema#date'), - }; - } else if (type === 'checkbox') { - options = { - datatype: namedNode('http://www.w3.org/2001/XMLSchema#boolean'), - }; - } - const field = this.store.create('shacl-form-field', options); - field.widget = type; - field.isSelect = type === 'dropdown'; - this.fields = [...this.fields, field]; + field = { + ...field, + ...this.getShaclDatatypeOrNodeKindFromWidgetType(type), + ...{ + minCount: 0, + maxCount: 1, + canHavePlaceholder: false, + canHaveChoiceBinding: false, + }, + }; } + + this.model.fields = [...this.model.fields, field]; } } @@ -157,72 +100,160 @@ export default class IndexController extends Controller { event.target.disabled = true; event.target.innerText = 'Saving...'; - if (!this.validateInputs()) { + if (!(await this.validateInputs())) { event.target.disabled = false; event.target.innerText = 'Save'; return; } - // Remove all N3 rules from the resource. - let matches = await this.model.removeN3RulesFromResource(); + // Update order of fields. + this.model.fields.forEach((field, index) => { + field.order = index; + }); - this.fields.forEach((field, i) => { - field.order = i; + // Calculate differences between the original form and the current form and save those using N3 Patch. + const fieldsToInsert = []; + const fieldsToDelete = []; - // Ugly reassignments to fix the bug where property was not written back as it was only assigned using binding. - field.label = field.label.trim(); - field.required = !field.required; - field.required = !field.required; - field.multiple = !field.multiple; - field.multiple = !field.multiple; - if (field.isSelect) { - field.options = [...field.options]; - field.options.forEach((option) => { - option.label = option.label.trim(); - }); + // Find all new fields. + this.model.fields.forEach((field) => { + // Check if it is not already in the original form by checking the uuid. + if (!this.model.originalFields.find((f) => f.uuid === field.uuid)) { + fieldsToInsert.push(field); } - if (field.canHavePlaceholder && field.placeholder) { - field.placeholder = field.placeholder?.trim(); + }); + + // Find all deleted fields. + this.model.originalFields.forEach((field) => { + // Check if it is not already in the current form by checking the uuid. + if (!this.model.fields.find((f) => f.uuid === field.uuid)) { + fieldsToDelete.push(field); } - if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { - field.minCount = field.minCount || 0; - field.maxCount = field.maxCount || 1; + }); + + // Find all the updated fields. + this.model.fields.forEach((field) => { + // Find the original field. + const originalField = this.model.originalFields.find( + (f) => f.uuid === field.uuid + ); + if (originalField) { + // Check if the field has been updated. + if ( + field.property !== originalField.property || + field.label !== originalField.label || + field.order !== originalField.order || + field.required !== originalField.required || + field.multiple !== originalField.multiple || + field.placeholder !== originalField.placeholder || + field.choice !== originalField.choice || + field.nodeKind !== originalField.nodeKind || + field.minCount !== originalField.minCount || + field.maxCount !== originalField.maxCount || + field.placeholder !== originalField.placeholder || + !this.optionsAreEqual(field.options, originalField.options) + ) { + fieldsToInsert.push(field); + fieldsToDelete.push(originalField); + } } }); - this.model.form.fields = this.fields; - await this.store.persist(); + if ( + this.model.formTargetClass === this.model.originalFormTargetClass && + fieldsToInsert.length === 0 && + fieldsToDelete.length === 0 && + this.policiesAreEqual(this.model.policies, this.model.originalPolicies) + ) { + this.model.success = 'No changes detected. No need to save!'; + event.target.disabled = false; + event.target.innerText = 'Save'; + return; + } + + // Remove all N3 rules from the resource. + let matches = await this.model.removeN3RulesFromResource(); + + console.log('old fields', this.model.originalFields); + console.log('new fields', this.model.fields); + + // Form the N3 Patch. + let n3Patch = ` + @prefix solid: . + @prefix ui: . + @prefix sh: . + @prefix form: . + @prefix xsd: . + @prefix skos: . + @prefix rdf: . + @prefix rdfs: . + @prefix owl: . + + _:test a solid:InsertDeletePatch; + solid:inserts { + ${this.stringifyFormSubject( + this.model.loadedFormUri, + this.model.formTargetClass, + this.model.fields + )} + ${this.stringifyFields( + this.model.loadedFormUri, + this.model.formTargetClass, + fieldsToInsert + )} + }`; + if (this.model.newForm) { + n3Patch += ` .`; + } else { + n3Patch += ` ; + solid:deletes { + ${this.stringifyFormSubject( + this.model.loadedFormUri, + this.model.originalFormTargetClass, + this.model.originalFields + )} + ${this.stringifyFields( + this.model.loadedFormUri, + this.model.originalFormTargetClass, + fieldsToDelete + )} + } . + `; + } + + // Apply the N3 Patch. + const response = await fetch(this.model.loadedFormUri, { + method: 'PATCH', + headers: { + 'Content-Type': 'text/n3', + }, + body: n3Patch, + }); + if (!response.ok) { + this.model.error = `Could not save the form definition!`; + + // We still need to re-add the N3 rules to the resource. + await this.model.addN3RulesToResource(matches); - // Apply the policy configuration to the rules. - matches.rules = await matches.rules.filter( - (rule) => !this.isEventSubmitRule(rule, matches.prefixes) + event.target.disabled = false; + event.target.innerText = 'Save'; + return; + } + + // Remove all N3 rules that are Submit event policies as we are regenerating them after this. + const keepRules = await Promise.all( + matches.rules.map( + async (rule) => await this.isEventSubmitRule(rule, matches.prefixes) + ) ); - matches.rules.push(` -{ - ?id ex:event ex:Submit. -} => { - ex:HttpPolicy pol:policy [ - a fno:Execution ; - fno:executes ex:httpRequest ; - ex:method "${this.model.policyMethod}" ; - ex:url <${this.model.policyURL}> ; - ex:contentType "${this.model.policyContentType}" - ] . -} . - `); - if (this.model.policyRedirectUrl) { - matches.rules.push(` -{ - ?id ex:event ex:Submit. -} => { - ex:RedirectPolicy pol:policy [ - a fno:Execution ; - fno:executes ex:redirect ; - ex:url <${this.model.policyRedirectUrl}> - ] . -} . - `); + matches.rules = matches.rules.filter((rule, index) => !keepRules[index]); + + // Add the generated N3 rules to the matches list. + for (const policy of this.stringifyPolicies(this.model.policies)) { + matches.rules.push(policy); } + + // Make sure the used prefixes are still part of the resource. matches.prefixes = this.model.addIfNotIncluded( matches.prefixes, 'ex', @@ -240,122 +271,147 @@ export default class IndexController extends Controller { ); // Re-add the N3 rules to the resource. - await this.model.addN3RulesToResource(matches); - - this.success = 'Successfully saved the form definition!'; + if (await this.model.addN3RulesToResource(matches)) { + this.model.success = 'Successfully saved the form definition!'; + + // On successful save, update the original fields to the current fields. + this.model.originalFields = JSON.parse(JSON.stringify(this.model.fields)); + this.model.originalPolicies = JSON.parse( + JSON.stringify(this.model.policies) + ); + this.model.originalFormTargetClass = this.model.formTargetClass; + } else { + this.model.error = `Could not save the policies as part of the form definition!`; + } + this.model.newForm = false; event.target.disabled = false; event.target.innerText = 'Save'; } - validateInputs() { + async validateInputs() { let valid = true; - if (!this.model.form?.binding?.value.trim()) { - this.model.form.error = 'Please fill in a binding.'; + if (!this.model.formTargetClass.trim()) { + this.model.formTargetClassError = 'Please fill in a binding.'; } - valid &= !this.model.form.error; + valid &= !this.model.formTargetClassError; - this.fields.forEach((field) => { - if (!field.binding?.value.trim()) { + this.model.fields.forEach((field) => { + if (!field.property?.trim()) { field.error = 'Please fill in a binding.'; } valid &= !field.error; if (field.isSelect) { field.options.forEach((option) => { - if (!option.binding?.value.trim()) { + if (!option.property?.trim()) { option.error = 'Please fill in a binding.'; } valid &= !option.error; }); if (field.canHaveChoiceBinding) { - valid &= !field.choice.error; + valid &= !field.choiceError; } } }); - if (!this.model.policyURL.trim()) { - this.policyURLError = 'Please fill in a URL.'; - valid = false; - } - if (!this.model.policyContentType.trim()) { - this.policyContentTypeError = 'Please fill in a Content-Type.'; - valid = false; - } - if ( - !['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes( - this.model.policyMethod - ) - ) { - this.policyMethodError = 'Please choose a valid HTTP method.'; - valid = false; - } - if (!['HTTP'].includes(this.model.policyType)) { - this.policyTypeError = 'Please choose a valid policy type.'; - valid = false; - } + this.model.policies.forEach((policy) => { + policy.urlError = ''; + policy.contentTypeError = ''; + + if (!policy.url.trim()) { + policy.urlError = 'Please fill in a URL.'; + } + valid &= !policy.urlError; + + if (policy.executionTarget === 'http://example.org/httpRequest') { + if ( + !['GET', 'POST', 'PUT', 'DELETE', 'PATCH'].includes(policy.method) + ) { + policy.methodError = 'Please choose a valid HTTP method.'; + } + valid &= !policy.methodError; + + if (!policy.contentType.trim()) { + policy.contentTypeError = 'Please fill in a Content-Type.'; + } + valid &= !policy.contentTypeError; + } + }); + + // Update the fields and policies to trigger a re-render. + await this.model.updateFields(); + await this.model.updatePolicies(); + return valid; } @action async updateBinding(element, event) { - this.error = null; + this.model.error = null; element.error = ''; - let binding = event.target.value?.trim(); + const result = await this.expandBinding(event.target.value); - if (!binding) { - element.error = 'Please fill in a binding.'; - return; + if (result.error) { + element.error = result.error; + } else { + element.property = result.binding; } - if (binding.includes(':')) { - if (!binding.includes('://')) { - binding = await this.replacePrefixInBinding(binding); - if (!binding) { - element.error = 'Please fill in a valid binding.'; - return; - } - } + // Update the fields to trigger a re-render. + await this.model.updateFields(); + } + + @action + async updateFormBinding() { + this.model.error = null; + this.model.formTargetClassError = ''; + + const result = await this.expandBinding(this.model.formTargetClass); + if (result.error) { + this.model.formTargetClassError = result.error; } else { - element.error = 'Please fill in a valid binding.'; - return; - } - if (element.isUiFormOption || element.isShaclFormOption) { - element.setUri(namedNode(binding)); + this.model.formTargetClass = result.binding; } - element.binding = namedNode(binding); } @action async updateChoiceBinding(element, event) { - this.error = null; - element.choice.error = ''; + this.model.error = null; + element.choiceError = ''; - let binding = event.target.value?.trim(); + const result = await this.expandBinding(event.target.value); + + if (result.error) { + element.choiceError = result.error; + } else { + element.choice = result.binding; + } + + // Update the fields to trigger a re-render. + await this.model.updateFields(); + } + + async expandBinding(binding_) { + let binding = binding_?.trim(); if (!binding) { - element.choice.error = 'Please fill in a binding.'; - return; + return { error: 'Please fill in a binding.' }; } if (binding.includes(':')) { if (!binding.includes('://')) { binding = await this.replacePrefixInBinding(binding); if (!binding) { - element.choice.error = 'Please fill in a valid binding.'; - return; + return { error: 'Please fill in a valid binding.' }; } } } else { - element.choice.error = 'Please fill in a valid binding.'; - return; + return { error: 'Please fill in a valid binding.' }; } - element.choice.setUri(namedNode(binding)); - element.options.forEach((option) => { - option.setRdfType(element.choice.uri); - }); + return { binding }; } async replacePrefixInBinding(binding) { @@ -369,142 +425,91 @@ export default class IndexController extends Controller { if (uri) { binding = uri + suffix; } else { - this.error = `Could not find a prefix for '${prefix}'!`; + this.model.error = `Could not find a prefix for '${prefix}'!`; return undefined; } return binding; } @action - addOption(field, event) { + async addOption(field, event) { event.preventDefault(); event.target.closest('.btn').disabled = true; + const baseUri = this.model.loadedFormUri.split('#')[0]; + let option = { uuid: uuid(), label: '', property: '' }; if ( this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' ) { - const option = this.store.create('rdf-form-option', {}); - field.options = [...field.options, option]; - } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { - const option = this.store.create('ui-form-option', {}); - option.setRdfType(field.choice.uri); - field.options = [...field.options, option]; + option = { + ...option, + uri: `${baseUri}#${uuid()}`, + listSubject: `${baseUri}#${uuid()}`, + }; } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { - const option = this.store.create('shacl-form-option', {}); - field.options = [...field.options, option]; + option = { + ...option, + listSubject: `${baseUri}#${uuid()}`, + }; } + field.options.push(option); + + // Update the fields to trigger a re-render. + await this.model.updateFields(); event.target.closest('.btn').disabled = false; } @action - removeOption(field, option, event) { + async removeOption(field, option, event) { event.preventDefault(); field.options = field.options.filter((o) => o.uuid !== option.uuid); - option.destroy(); + // Update the fields to trigger a re-render. + await this.model.updateFields(); } @action removeField(field, event) { event?.preventDefault(); - - // Remove all the options of the field if it is a select. - if (field.isSelect) { - field.options.forEach((option) => { - option.destroy(); - }); - field.options.clear(); - - if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { - field.choice.destroy(); - } - } - field.required = null; - field.multiple = null; - this.fields = this.fields.filter((f) => f.uuid !== field.uuid); - field.destroy(); + this.model.fields = this.model.fields.filter((f) => f.uuid !== field.uuid); } @action changeVocabulary(event) { // Clear any existing form. - this.clearForm(); + this.model.clearForm(); // Update the vocabulary. this.model.vocabulary = event.target.value; - - // Create new form. - if ( - this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' - ) { - this.model.initiateNewRdfForm(); - } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { - this.model.initiateNewSolidUiForm(this.fields); - } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { - this.model.initiateNewShaclForm(); - } - - this.isRdfFormVocabulary = - this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#'; - this.isSolidUiVocabulary = - this.model.vocabulary === 'http://www.w3.org/ns/ui#'; - this.isShaclVocabulary = - this.model.vocabulary === 'http://www.w3.org/ns/shacl#'; } getSolidUiRdfTypeFromWidgetType(type) { - console.log(type); if (type === 'string') { - console.log('here'); - return namedNode('http://www.w3.org/ns/ui#SingleLineTextField'); + return 'http://www.w3.org/ns/ui#SingleLineTextField'; } else if (type === 'textarea') { - return namedNode('http://www.w3.org/ns/ui#MultiLineTextField'); + return 'http://www.w3.org/ns/ui#MultiLineTextField'; } else if (type === 'dropdown') { - return namedNode('http://www.w3.org/ns/ui#Choice'); + return 'http://www.w3.org/ns/ui#Choice'; } else if (type === 'date') { - return namedNode('http://www.w3.org/ns/ui#DateField'); + return 'http://www.w3.org/ns/ui#DateField'; } else if (type === 'checkbox') { - return namedNode('http://www.w3.org/ns/ui#BooleanField'); + return 'http://www.w3.org/ns/ui#BooleanField'; } else { return null; } } - getWidgetTypeFromSolidUiRdfType(type) { - if (type.value === 'http://www.w3.org/ns/ui#SingleLineTextField') { - return 'string'; - } else if (type.value === 'http://www.w3.org/ns/ui#MultiLineTextField') { - return 'textarea'; - } else if (type.value === 'http://www.w3.org/ns/ui#Choice') { - return 'dropdown'; - } else if (type.value === 'http://www.w3.org/ns/ui#DateField') { - return 'date'; - } else if (type.value === 'http://www.w3.org/ns/ui#BooleanField') { - return 'checkbox'; - } else { - return null; - } - } - - getWidgetTypeFromShaclDatatypeOrNodeKind(datatype, nodeKind) { - if (datatype) { - if (datatype.value === 'http://www.w3.org/2001/XMLSchema#string') { - return 'string'; - } else if (datatype.value === 'http://www.w3.org/2001/XMLSchema#date') { - return 'date'; - } else if ( - datatype.value === 'http://www.w3.org/2001/XMLSchema#boolean' - ) { - return 'checkbox'; - } else { - return null; - } - } else if (nodeKind) { - if (nodeKind.value === 'http://www.w3.org/ns/shacl#IRI') { - return 'dropdown'; - } else { - return null; - } + getShaclDatatypeOrNodeKindFromWidgetType(type) { + if (type === 'string') { + return { type: 'http://www.w3.org/2001/XMLSchema#string' }; + } else if (type === 'textarea') { + return { type: 'http://www.w3.org/2001/XMLSchema#string' }; + } else if (type === 'dropdown') { + return { nodeKind: 'http://www.w3.org/ns/shacl#IRI' }; + } else if (type === 'date') { + return { type: 'http://www.w3.org/2001/XMLSchema#date' }; + } else if (type === 'checkbox') { + return { type: 'http://www.w3.org/2001/XMLSchema#boolean' }; } else { return null; } @@ -512,26 +517,17 @@ export default class IndexController extends Controller { @action clearSuccess() { - this.success = null; + this.model.success = null; } @action clearError() { - this.error = null; + this.model.error = null; } - clearForm() { - this.fields?.forEach((field) => { - this.removeField(field); - }); - this.model.form?.destroy(); - this.model.supportedClass?.destroy(); - } - - clearPolicyInfo() { - this.model.policyURL = ''; - this.model.policyContentType = ''; - this.model.policyRedirectUrl = ''; + @action + clearInfo() { + this.model.info = null; } @action @@ -540,39 +536,20 @@ export default class IndexController extends Controller { document.getElementById('load-btn').disabled = true; document.getElementById('load-btn').innerText = 'Loading...'; - this.clearPolicyInfo(); - this.clearForm(); - this.form = this.model.loadedFormUri; - this.model.configureStorageLocations(this.form); - await this.model.fetchGraphs(true); - - this.model.loadForm(); - this.isRdfFormVocabulary = - this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#'; - this.isSolidUiVocabulary = - this.model.vocabulary === 'http://www.w3.org/ns/ui#'; - this.isShaclVocabulary = - this.model.vocabulary === 'http://www.w3.org/ns/shacl#'; - - this.fields = this.loadFields(); + await this.model.loadForm(this.form); document.getElementById('load-btn').disabled = false; document.getElementById('load-btn').innerText = 'Load'; } - @action - async logout() { - await this.solidAuth.ensureLogout(); - this.model.refresh(); - } - async isEventSubmitRule(rule, prefixes) { - const options = { blogic: false, outputType: 'string' }; + const options = { outputType: 'string' }; const query = `${prefixes ? prefixes.join('\n') : ''}\n${rule}`; + // TODO: We should replace ?id with the actual form URI. const reasonerResult = await n3reasoner( - '_:id .', + '?id .', query, options ); @@ -580,28 +557,402 @@ export default class IndexController extends Controller { } @action - updatePolicyType(event) { - console.log(event.target.value?.trim()); - this.model.policyType = event.target.value?.trim(); + updatePolicyMethod(policy, event) { + policy.method = event.target.value?.trim(); } @action - updatePolicyURL(event) { - this.model.policyURL = event.target.value?.trim(); + addPolicy(type) { + if (type && type.startsWith('policy-')) { + let policy = { uuid: uuid(), url: '' }; + if (type === 'policy-redirect') { + policy.executionTarget = 'http://example.org/redirect'; + } else if (type === 'policy-n3-patch') { + policy.executionTarget = 'http://example.org/n3Patch'; + } else if (type === 'policy-http-request') { + policy = { + ...policy, + method: 'POST', + contentType: '', + executionTarget: 'http://example.org/httpRequest', + }; + } + this.model.policies = [...this.model.policies, policy]; + } } @action - updatePolicyMethod(event) { - this.model.policyMethod = event.target.value?.trim(); + removePolicy(policy, event) { + event?.preventDefault(); + this.model.policies = this.model.policies.filter( + (p) => p.uuid !== policy.uuid + ); } @action - updatePolicyContentType(event) { - this.model.policyContentType = event.target.value?.trim(); + async login() { + await handleIncomingRedirect(); + + // 2. Start the Login Process if not already logged in. + if (!getDefaultSession().info.isLoggedIn) { + await login({ + // Specify the URL of the user's Solid Identity Provider; + // e.g., "https://login.inrupt.com". + oidcIssuer: this.oidcIssuer, + // Specify the URL the Solid Identity Provider should redirect the user once logged in, + // e.g., the current page for a single-page app. + redirectUrl: window.location.href, + // Provide a name for the application when sending to the Solid Identity Provider + clientName: 'FormGenerator', + }).catch((e) => { + this.authError = e.message; + }); + } } @action - updatePolicyRedirectUrl(event) { - this.model.policyRedirectUrl = event.target.value?.trim(); + async logout() { + await logout(); + this.model.loggedIn = undefined; + } + + /** + * Stringifies the fields. + * Requires the following prefixes to be defined: + * @prefix ui: . + * @prefix form: . + * @prefix sh: . + * @prefix rdf: . + * @prefix rdfs: . + * @prefix owl: . + * @prefix xsd: . + * @prefix skos: . + */ + stringifyFields(formUri, targetClass, fields) { + if ( + this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' + ) { + return this.stringifyRdfFormFields(formUri, targetClass, fields); + } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { + return this.stringifySoldUiFields(formUri, targetClass, fields); + } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { + return this.stringifyShaclFields(formUri, targetClass, fields); + } else { + console.error('Unknown vocabulary', this.model.vocabulary); + return ''; + } + } + + /** + * Stringifies the fields in the Solid-UI vocabulary. + * Requires the following prefixes to be defined: + * @prefix ui: . + * @prefix xsd: . + * @prefix skos: . + */ + stringifySoldUiFields(formUri, targetClass, fields) { + let data = ''; + for (const field of fields) { + data += `<${field.uri}> a <${field.type}> .\n`; + data += `<${field.uri}> ui:property <${field.property}> .\n`; + if (field.label) { + data += `<${field.uri}> ui:label "${field.label}" .\n`; + } + if (field.required !== undefined) { + data += `<${field.uri}> ui:required "${field.required}"^^xsd:boolean .\n`; + } + if (field.multiple !== undefined) { + data += `<${field.uri}> ui:multiple "${field.multiple}"^^xsd:boolean .\n`; + } + if (field.order !== undefined) { + data += `<${field.uri}> ui:sequence ${field.order} .\n`; + } + if (field.choice) { + data += `<${field.uri}> ui:from <${field.choice}> .\n`; + + // Stringify the options. + for (const option of field.options) { + data += `<${option.property}> a <${field.choice}> .\n`; + if (option.label) { + data += `<${option.property}> skos:prefLabel "${option.label}" .\n`; + } + } + } + } + return data; + } + + /** + * Stringifies the fields in the SHACL vocabulary. + * Requires the following prefixes to be defined: + * @prefix sh: . + * @prefix rdf: . + * @prefix rdfs: . + * @prefix owl: . + */ + stringifyShaclFields(formUri, targetClass, fields) { + let data = ''; + for (const field of fields) { + data += `<${field.uri}> a sh:PropertyShape .\n`; + if (field.property) { + data += `<${field.uri}> sh:path <${field.property}> .\n`; + } + if (field.type) { + data += `<${field.uri}> sh:datatype <${field.type}> .\n`; + } + if (field.nodeKind) { + data += `<${field.uri}> sh:nodeKind <${field.nodeKind}> .\n`; + } + if (field.minCount !== undefined) { + data += `<${field.uri}> sh:minCount ${field.minCount} .\n`; + } + if (field.maxCount !== undefined) { + data += `<${field.uri}> sh:maxCount ${field.maxCount} .\n`; + } + if (field.label) { + data += `<${field.uri}> sh:name "${field.label}" .\n`; + } + if (field.order !== undefined) { + data += `<${field.uri}> sh:order ${field.order} .\n`; + } + if (field.isSelect) { + data += `<${field.uri}> sh:in ${ + field.options.length ? `<${field.options[0].listSubject}>` : 'rdf:nil' + } .\n`; + + // Stringify the options. + for (const option of field.options) { + data += `<${option.property}> a owl:Class .\n`; + if (option.label) { + data += `<${option.property}> rdfs:label "${option.label}" .\n`; + } + } + + // Stringify RDF List. + for (const [index, option] of field.options.entries()) { + data += `<${option.listSubject}> rdf:first <${option.property}> .\n`; + data += `<${option.listSubject}> rdf:rest ${ + index === field.options.length - 1 + ? 'rdf:nil' + : `<${field.options[index + 1].listSubject}>` + } .\n`; + } + } + } + return data; + } + + /** + * Stringifies the fields in the RDF Form vocabulary. + * Requires the following prefixes to be defined: + * @prefix form: . + * @prefix xsd: . + * @prefix rdf: . + */ + stringifyRdfFormFields(formUri, targetClass, fields) { + let data = ''; + for (const field of fields) { + data += `<${field.uri}> a form:Field .\n`; + data += `<${field.uri}> form:widget "${field.type}" .\n`; + if (field.property) { + data += `<${field.uri}> form:binding <${field.property}> .\n`; + } + if (field.label) { + data += `<${field.uri}> form:label "${field.label}" .\n`; + } + if (field.order !== undefined) { + data += `<${field.uri}> form:order ${field.order} .\n`; + } + if (field.required !== undefined) { + data += `<${field.uri}> form:required "${field.required}"^^xsd:boolean .\n`; + } + if (field.multiple !== undefined) { + data += `<${field.uri}> form:multiple "${field.multiple}"^^xsd:boolean .\n`; + } + if (field.placeholder) { + data += `<${field.uri}> form:placeholder "${field.placeholder}" .\n`; + } + if (field.isSelect) { + data += `<${field.uri}> form:option ${ + field.options.length ? `<${field.options[0].listSubject}>` : 'rdf:nil' + } .\n`; + + // Stringify the options. + for (const option of field.options) { + if (option.property) { + data += `<${option.uri}> form:value <${option.property}> .\n`; + } + if (option.label) { + data += `<${option.uri}> form:label "${option.label}" .\n`; + } + } + + // Stringify RDF List. + for (const [index, option] of field.options.entries()) { + data += `<${option.listSubject}> rdf:first <${option.uri}> .\n`; + data += `<${option.listSubject}> rdf:rest ${ + index === field.options.length - 1 + ? 'rdf:nil' + : `<${field.options[index + 1].listSubject}>` + } .\n`; + } + } + } + return data; + } + + /** + * Stringifies the form subject. + * Requires the following prefixes to be defined: + * @prefix ui: . + * @prefix form: . + * @prefix sh: . + * @prefix rdf: . + */ + stringifyFormSubject(formUri, targetClass, fields) { + if ( + this.model.vocabulary === 'http://rdf.danielbeeke.nl/form/form-dev.ttl#' + ) { + return this.stringifyRdfFormSubject(formUri, targetClass); + } else if (this.model.vocabulary === 'http://www.w3.org/ns/ui#') { + return this.stringifySolidUiFormSubject(formUri, targetClass, fields); + } else if (this.model.vocabulary === 'http://www.w3.org/ns/shacl#') { + return this.stringifyShaclFormSubject(formUri, targetClass, fields); + } else { + console.error('Unknown vocabulary', this.model.vocabulary); + return ''; + } + } + + /** + * Stringifies the form subject in the Solid-UI vocabulary. + * Requires the following prefixes to be defined: + * @prefix ui: . + * @prefix rdf: . + */ + stringifySolidUiFormSubject(formUri, targetClass, fields) { + let data = `<${formUri}> a ui:Form .\n`; + if (targetClass) { + data += `<${formUri}> ui:property <${targetClass}> .\n`; + } + data += `<${formUri}> ui:parts ${ + fields.length ? `<${fields[0].listSubject}>` : 'rdf:nil' + } .\n`; + for (const [index, field] of fields.entries()) { + data += `<${field.listSubject}> rdf:first <${field.uri}> .\n`; + data += `<${field.listSubject}> rdf:rest ${ + index === fields.length - 1 + ? 'rdf:nil' + : `<${fields[index + 1].listSubject}>` + } .\n`; + } + return data; + } + + /** + * Stringifies the form subject in the SHACL vocabulary. + * Requires the following prefixes to be defined: + * @prefix sh: . + */ + stringifyShaclFormSubject(formUri, targetClass, fields) { + let data = `<${formUri}> a sh:NodeShape .\n`; + if (targetClass) { + data += `<${formUri}> sh:targetClass <${targetClass}> .\n`; + } + for (const field of fields) { + data += `<${formUri}> sh:property <${field.uri}> .\n`; + } + return data; + } + + /** + * Stringifies the form subject in the RDF Form vocabulary. + * Requires the following prefixes to be defined: + * @prefix form: . + */ + stringifyRdfFormSubject(formUri, targetClass) { + let data = `<${formUri}> a form:Form .\n`; + if (targetClass) { + data += `<${formUri}> form:binding <${targetClass}> .\n`; + } + return data; + } + + stringifyPolicies(policies) { + const list = []; + for (const policy of policies) { + // Add basic properties and N3 rule syntax equal for all policy types. + let data = ` +{ + <${this.model.loadedFormUri}> ex:event ex:Submit. +} => { + ex:HttpPolicy pol:policy [ + a fno:Execution ; + fno:executes <${policy.executionTarget}> ; + ex:url <${policy.url}>`; + + // If the policy is a HTTP request, add the method and content type. + if (policy.executionTarget === 'http://example.org/httpRequest') { + data += ` ; + ex:method "${policy.method}" ; + ex:contentType "${policy.contentType}"`; + } + + // Finish the policy syntax. + data += ` + ] . +} . + `; + + list.push(data); + } + return list; + } + + optionsAreEqual(a, b) { + if (!a) { + return !b; + } + if (a.length !== b.length) { + return false; + } + for (const option of a) { + const equivalentOption = b.find((o) => o.uuid === option.uuid); + if (!equivalentOption) { + return false; + } + if ( + option.property !== equivalentOption.property || + option.label !== equivalentOption.label || + option.uri !== equivalentOption.uri + ) { + return false; + } + } + return true; + } + + policiesAreEqual(a, b) { + if (!a) { + return !b; + } + if (a.length !== b.length) { + return false; + } + for (const policy of a) { + const equivalentPolicy = b.find((p) => p.uuid === policy.uuid); + if (!equivalentPolicy) { + return false; + } + if ( + policy.url !== equivalentPolicy.url || + policy.executionTarget !== equivalentPolicy.executionTarget || + policy.method !== equivalentPolicy.method || + policy.contentType !== equivalentPolicy.contentType + ) { + return false; + } + } + return true; } } diff --git a/app/routes/application.js b/app/routes/application.js index 066c83a..8a4e028 100644 --- a/app/routes/application.js +++ b/app/routes/application.js @@ -1,5 +1,5 @@ -import { inject as service } from '@ember/service'; import Route from '@ember/routing/route'; +import { service } from '@ember/service'; export default class ApplicationRoute extends Route { @service solidAuth; diff --git a/app/routes/index.js b/app/routes/index.js index dfa2269..f8bd1d5 100644 --- a/app/routes/index.js +++ b/app/routes/index.js @@ -1,10 +1,11 @@ import Route from '@ember/routing/route'; -import { inject as service } from '@ember/service'; import { v4 as uuid } from 'uuid'; -import { namedNode } from 'rdflib'; import { tracked } from '@glimmer/tracking'; import { n3reasoner } from 'eyereasoner'; import { QueryEngine } from '@comunica/query-sparql'; +import { fetch } from '@smessie/solid-client-authn-browser'; +import { findStorageRoot } from 'solid-storage-root'; +import { service } from '@ember/service'; export default class IndexRoute extends Route { queryParams = { @@ -14,10 +15,6 @@ export default class IndexRoute extends Route { }; @service solidAuth; - @service store; - - supportedClass; - @tracked form; @tracked loadedFormUri; @@ -25,131 +22,143 @@ export default class IndexRoute extends Route { engine = new QueryEngine(); - @tracked policyType = 'HTTP'; - @tracked policyURL = ''; - @tracked policyMethod = 'POST'; - @tracked policyContentType = ''; - @tracked policyRedirectUrl = ''; - - async model({ form }) { - await this.solidAuth.ensureLogin(); + originalFields = []; + originalPolicies = []; + originalFormTargetClass = ''; + @tracked fields = []; + @tracked policies = []; + @tracked formTargetClass; + @tracked formTargetClassError = ''; - console.log('editing form', form); - this.configureStorageLocations(form); + newForm = true; - await this.fetchGraphs(form !== null); + @tracked success = null; + @tracked error = null; + @tracked info = null; + async model({ form }) { if (form) { - const loadedForm = this.loadForm(); - if (!loadedForm) { - this.initiateNewShaclForm(); + const loadedForm = this.loadForm(form); + if (loadedForm) { + this.loadedFormUri = form; } } else { - this.initiateNewShaclForm(); + this.newForm = true; + if (this.solidAuth.loggedIn) { + const storageRoot = await findStorageRoot( + this.solidAuth.loggedIn, + fetch + ); + this.loadedFormUri = `${storageRoot}private/tests/forms/${uuid()}.n3#${uuid()}`; + } else { + this.info = + 'Log in to use a random location in your Solid pod or manually provide a form URI to get started.'; + } } return this; } - configureStorageLocations(form) { - const storageLocation = form ? form : `private/tests/forms/${uuid()}.ttl`; - this.loadedFormUri = storageLocation; - this.store.classForModel('hydra-class').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('rdf-form').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('rdf-form-field').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('rdf-form-option').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('ui-form').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('ui-form-field').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('ui-form-option').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('ui-form-choice').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('shacl-form').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('shacl-form-field').solid.defaultStorageLocation = - storageLocation; - this.store.classForModel('shacl-form-option').solid.defaultStorageLocation = - storageLocation; - } + async loadForm(formUri) { + this.clearForm(); - async fetchGraphs(removeN3Rules = false) { - // Remove all N3 rules from the resource. - let matches = { rules: [], prefixes: [] }; - if (removeN3Rules) { - matches = await this.removeN3RulesFromResource(); + if (!formUri) { + this.newForm = true; + return false; } + await this.loadPolicies(formUri); - await this.store.fetchGraphForType('hydra-class', true); - await this.store.fetchGraphForType('rdf-form', true); - await this.store.fetchGraphForType('rdf-form-field', true); - await this.store.fetchGraphForType('rdf-form-option', true); - await this.store.fetchGraphForType('ui-form', true); - await this.store.fetchGraphForType('ui-form-field', true); - await this.store.fetchGraphForType('ui-form-option', true); - await this.store.fetchGraphForType('ui-form-choice', true); - await this.store.fetchGraphForType('shacl-form', true); - await this.store.fetchGraphForType('shacl-form-field', true); - await this.store.fetchGraphForType('shacl-form-option', true); - - // Add N3 rules back to the resource. - if (removeN3Rules) { - await this.addN3RulesToResource(matches); + if (await this.loadSolidUiForm(formUri)) { + this.vocabulary = 'http://www.w3.org/ns/ui#'; + } else if (await this.loadShaclForm(formUri)) { + this.vocabulary = 'http://www.w3.org/ns/shacl#'; + } else if (await this.loadRdfFormForm(formUri)) { + this.vocabulary = 'http://rdf.danielbeeke.nl/form/form-dev.ttl#'; + } else { + this.newForm = true; + return false; } - - // Fill in the form with submit event policy values. - await this.fillInFormWithSubmitEventPolicy(matches); + this.newForm = false; + this.originalFields = JSON.parse(JSON.stringify(this.fields)); + this.originalPolicies = JSON.parse(JSON.stringify(this.policies)); + this.originalFormTargetClass = this.formTargetClass; + console.log('Form loaded: ', this.fields); + return true; } - initiateNewRdfForm() { - this.supportedClass = this.store.create('hydra-class', { - method: 'POST', - }); - this.form = this.store.create('rdf-form', { - endpoint: namedNode('https://httpbin.org/post'), - supportedClass: this.supportedClass, + async loadPolicies(formUri) { + // Get resource content. + const response = await fetch(formUri, { + method: 'GET', }); - } + if (!response.ok) { + this.error = 'Could not load form.'; + return; + } + const content = await response.text(); + + // Get policies from footprint tasks. + const options = { outputType: 'string' }; + // TODO: We should replace ?id with the actual form URI. + const reasonerResult = await n3reasoner( + `?id .`, + content, + options + ); - initiateNewSolidUiForm(fields) { - this.form = this.store.create('ui-form', { - fields: fields, - }); - } + // Parse policies. + const queryPolicy = ` + PREFIX ex: + PREFIX pol: + PREFIX fno: - initiateNewShaclForm() { - this.form = this.store.create('shacl-form', {}); + SELECT ?executionTarget ?method ?url ?contentType WHERE { + ?id pol:policy ?policy . + ?policy a fno:Execution . + ?policy fno:executes ?executionTarget . + ?policy ex:url ?url . + OPTIONAL { ?policy ex:method ?method } . + OPTIONAL { ?policy ex:contentType ?contentType } . + } + `; + const bindings = await ( + await this.engine.queryBindings(queryPolicy, { + sources: [ + { + type: 'stringSource', + value: reasonerResult, + mediaType: 'text/n3', + baseIRI: formUri.split('#')[0], + }, + ], + }) + ).toArray(); + + this.policies = bindings.map((row) => { + return { + uuid: uuid(), + executionTarget: row.get('executionTarget').value, + url: row.get('url').value, + method: row.get('method')?.value, + contentType: row.get('contentType')?.value, + }; + }); } - loadForm() { - // Try rdf-form vocabulary first. - this.supportedClass = this.store.all('hydra-class')[0]; - this.form = this.store.all('rdf-form')[0]; - this.vocabulary = 'http://rdf.danielbeeke.nl/form/form-dev.ttl#'; - if (this.form === undefined) { - // Try solid-ui vocabulary. - this.form = this.store.all('ui-form')[0]; - this.vocabulary = 'http://www.w3.org/ns/ui#'; - } - if (this.form === undefined) { - // Try shacl vocabulary. - this.form = this.store.all('shacl-form')[0]; - this.vocabulary = 'http://www.w3.org/ns/shacl#'; - } - console.log('loaded form', this.form); - console.log('loaded supportedClass', this.supportedClass); - return this.form !== undefined; + clearForm() { + this.fields = []; + this.policies = []; + this.originalFields = []; + this.originalPolicies = []; + this.formTargetClass = ''; + this.originalFormTargetClass = ''; + this.formTargetClassError = ''; + this.success = null; + this.error = null; } async removeN3RulesFromResource() { - const fetch = this.solidAuth.session.fetch; - const response = await fetch( - new URL(this.loadedFormUri, await this.solidAuth.podBase).href, + new URL(this.loadedFormUri /*, await this.solidAuth.podBase*/).href, { method: 'GET', } @@ -180,43 +189,33 @@ export default class IndexRoute extends Route { }); // Save resource without N3 rules. - await fetch( - new URL(this.loadedFormUri, await this.solidAuth.podBase).href, - { - method: 'PUT', - headers: { - 'Content-Type': contentType, - }, - body: text, - } - ); + await fetch(this.loadedFormUri, { + method: 'PUT', + headers: { + 'Content-Type': contentType, + }, + body: text, + }); return { rules: rules || [], prefixes: prefixes || [] }; } async addN3RulesToResource(matches) { const { rules, prefixes } = matches; - const fetch = this.solidAuth.session.fetch; if (!rules) { // No rules to add. - return; + return true; } - const response = await fetch( - new URL(this.loadedFormUri, await this.solidAuth.podBase).href, - { - method: 'GET', - } - ); + const response = await fetch(this.loadedFormUri, { + method: 'GET', + }); if (!response.ok) { - return; + return false; } - // Get content-type. - const contentType = response.headers.get('content-type'); - // Get content. let text = await response.text(); @@ -237,94 +236,339 @@ export default class IndexRoute extends Route { }); // Save resource with N3 rules. - await fetch( - new URL(this.loadedFormUri, await this.solidAuth.podBase).href, - { - method: 'PUT', - headers: { - 'Content-Type': contentType, - }, - body: text, + const response2 = await fetch(this.loadedFormUri, { + method: 'PUT', + headers: { + 'Content-Type': 'text/n3', + }, + body: text, + }); + + return response2.ok; + } + + async loadSolidUiForm(uri) { + const query = ` + PREFIX ui: + PREFIX rdf: + + SELECT ?targetClass ?type ?field ?property ?label ?from ?required ?multiple ?sequence ?listSubject + WHERE { + <${uri}> a ui:Form; + ui:parts ?list ; + ui:property ?targetClass . + ?list rdf:rest*/rdf:first ?field . + ?listSubject rdf:first ?field . + ?field a ?type; + ui:property ?property. + OPTIONAL { ?field ui:label ?label. } + OPTIONAL { ?field ui:from ?from. } + OPTIONAL { ?field ui:required ?required. } + OPTIONAL { ?field ui:multiple ?multiple. } + OPTIONAL { ?field ui:sequence ?sequence. } } - ); + `; + + const bindings = await ( + await this.engine.queryBindings(query, { sources: [uri], fetch }) + ).toArray(); + + if (!bindings.length) { + return false; + } + + let formTargetClass; + const fields = bindings.map((row) => { + formTargetClass = row.get('targetClass').value; + return { + uuid: uuid(), + uri: row.get('field').value, + type: row.get('type').value, + widget: this.solidUiTypeToWidget(row.get('type').value), + property: row.get('property').value, + label: row.get('label')?.value, + choice: row.get('from')?.value, + required: row.get('required')?.value === 'true', + multiple: row.get('multiple')?.value === 'true', + order: parseInt(row.get('sequence')?.value), + listSubject: row.get('listSubject').value, + canHavePlaceholder: false, + canHaveChoiceBinding: true, + }; + }); + + // Sort fields by order + fields.sort((a, b) => a.order - b.order); + + // Add options to Choice fields + for (const field of fields) { + if (field.type === 'http://www.w3.org/ns/ui#Choice') { + field.options = []; + field.isSelect = true; + const query = ` + PREFIX ui: + PREFIX rdf: + PREFIX skos: + SELECT ?value ?label WHERE { + ?value a <${field.choice}> . + OPTIONAL { ?value skos:prefLabel ?label . } + } + `; + + const bindings = await ( + await this.engine.queryBindings(query, { sources: [uri], fetch }) + ).toArray(); + + field.options = bindings.map((row) => { + return { + uuid: uuid(), + property: row.get('value').value, + label: row.get('label')?.value, + }; + }); + } + } + + this.formTargetClass = formTargetClass; + this.fields = fields; + + return true; } - async fillInFormWithSubmitEventPolicy(matches) { - matches.prefixes = this.addIfNotIncluded( - matches.prefixes, - 'ex', - 'http://example.org/' - ); - matches.prefixes = this.addIfNotIncluded( - matches.prefixes, - 'fno', - 'https://w3id.org/function/ontology#' - ); - matches.prefixes = this.addIfNotIncluded( - matches.prefixes, - 'pol', - 'https://www.example.org/ns/policy#' - ); + async loadShaclForm(uri) { + const query = ` + PREFIX sh: + + SELECT ?targetClass ?type ?field ?nodeKind ?property ?label ?order ?minCount ?maxCount ?in + WHERE { + <${uri}> a sh:NodeShape; + sh:targetClass ?targetClass ; + sh:property ?field . + ?field a sh:PropertyShape . + OPTIONAL { ?field sh:datatype ?type . } + OPTIONAL { ?field sh:nodeKind ?nodeKind . } + OPTIONAL { ?field sh:path ?property . } + OPTIONAL { ?field sh:name ?label . } + OPTIONAL { ?field sh:order ?order . } + OPTIONAL { ?field sh:minCount ?minCount . } + OPTIONAL { ?field sh:maxCount ?maxCount . } + OPTIONAL { ?field sh:in ?in . } + }`; + + const bindings = await ( + await this.engine.queryBindings(query, { sources: [uri], fetch }) + ).toArray(); + + console.log('bindings', bindings); + + if (!bindings.length) { + return false; + } - for (const rule of matches?.rules || []) { - const options = { blogic: false, outputType: 'string' }; - const query = `${ - matches.prefixes ? matches.prefixes.join('\n') : '' - }\n${rule}`; - const reasonerResult = await n3reasoner( - '_:id .', - query, - options - ); - - const queryPolicy = ` - PREFIX ex: - PREFIX pol: - PREFIX fno: + let formTargetClass; + const fields = bindings.map((row) => { + formTargetClass = row.get('targetClass').value; + return { + uuid: uuid(), + uri: row.get('field').value, + type: row.get('type')?.value, + widget: this.shaclTypeToWidget( + row.get('type')?.value || row.get('nodeKind')?.value + ), + nodeKind: row.get('nodeKind')?.value, + property: row.get('property')?.value, + label: row.get('label')?.value, + order: parseInt(row.get('order')?.value), + minCount: parseInt(row.get('minCount')?.value), + maxCount: parseInt(row.get('maxCount')?.value), + in: row.get('in')?.value, + canHavePlaceholder: false, + canHaveChoiceBinding: false, + }; + }); - SELECT ?executionTarget ?method ?url ?contentType WHERE { - ?id pol:policy ?policy . - ?policy a fno:Execution . - ?policy fno:executes ?executionTarget . - ?policy ex:url ?url . - OPTIONAL { ?policy ex:method ?method } . - OPTIONAL { ?policy ex:contentType ?contentType } . + // Sort fields by order + fields.sort((a, b) => a.order - b.order); + + // Add options to Choice fields (in case of sh:in) + for (const field of fields) { + if (field.in) { + field.options = []; + field.isSelect = true; + const query = ` + PREFIX rdf: + PREFIX owl: + PREFIX rdfs: + + SELECT ?option ?label ?listSubject + WHERE { + <${field.in}> rdf:rest*/rdf:first ?option . + ?listSubject rdf:first ?option . + ?option a owl:Class . + OPTIONAL { ?option rdfs:label ?label . } + }`; + + const bindings = await ( + await this.engine.queryBindings(query, { sources: [uri], fetch }) + ).toArray(); + + field.options = bindings.map((row) => { + return { + uuid: uuid(), + property: row.get('option').value, + label: row.get('label')?.value, + listSubject: row.get('listSubject').value, + }; + }); } - `; - const bindings = await ( - await this.engine.queryBindings(queryPolicy, { - sources: [ - { - type: 'stringSource', - value: reasonerResult, - mediaType: 'text/n3', - baseIRI: new URL(this.loadedFormUri, await this.solidAuth.podBase) - .href, - }, - ], - }) - ).toArray(); - - const policies = bindings.map((row) => { - return { - executionTarget: row.get('executionTarget').value, - url: row.get('url').value, - method: row.get('method')?.value, - contentType: row.get('contentType')?.value, - }; - }); - - for (const policy of policies) { - if (policy.executionTarget === 'http://example.org/httpRequest') { - this.policyType = 'HTTP'; - this.policyURL = policy.url; - this.policyMethod = policy.method; - this.policyContentType = policy.contentType; - } else if (policy.executionTarget === 'http://example.org/redirect') { - this.policyRedirectUrl = policy.url; - } + } + + this.formTargetClass = formTargetClass; + this.fields = fields; + + return true; + } + + async loadRdfFormForm(uri) { + const query = ` + PREFIX form: + PREFIX rdf: + + SELECT ?targetClass ?field ?type ?property ?label ?order ?required ?multiple ?placeholder + WHERE { + <${uri}> a form:Form; + form:binding ?targetClass . + ?field a form:Field; + form:widget ?type . + OPTIONAL { ?field form:binding ?property. } + OPTIONAL { ?field form:label ?label. } + OPTIONAL { ?field form:order ?order. } + OPTIONAL { ?field form:required ?required. } + OPTIONAL { ?field form:multiple ?multiple. } + OPTIONAL { ?field form:placeholder ?placeholder. } + }`; + + const bindings = await ( + await this.engine.queryBindings(query, { sources: [uri], fetch }) + ).toArray(); + + if (!bindings.length) { + return false; + } + + let formTargetClass; + const fields = bindings.map((row) => { + formTargetClass = row.get('targetClass').value; + return { + uuid: uuid(), + uri: row.get('field').value, + type: row.get('type').value, + widget: row.get('type').value, + property: row.get('property')?.value, + label: row.get('label')?.value, + order: parseInt(row.get('order')?.value), + required: row.get('required')?.value === 'true', + multiple: row.get('multiple')?.value === 'true', + placeholder: row.get('placeholder')?.value, + canHavePlaceholder: + row.get('type').value === 'string' || + row.get('type').value === 'textarea', + canHaveChoiceBinding: false, + }; + }); + + // Sort fields by order + fields.sort((a, b) => a.order - b.order); + + // Add options to Choice fields (in case of type = "dropdown") + for (const field of fields) { + if (field.type === 'dropdown') { + field.options = []; + field.isSelect = true; + const query = ` + PREFIX form: + PREFIX rdf: + + SELECT ?value ?label ?option ?options ?listSubject + WHERE { + <${field.uri}> form:option ?options . + ?options rdf:rest*/rdf:first ?option . + ?listSubject rdf:first ?option . + OPTIONAL { ?option form:value ?value . } + OPTIONAL { ?option form:label ?label . } + } + `; + + const bindings = await ( + await this.engine.queryBindings(query, { sources: [uri], fetch }) + ).toArray(); + + field.options = bindings.map((row) => { + return { + uuid: uuid(), + property: row.get('value')?.value, + label: row.get('label')?.value, + uri: row.get('option').value, + listSubject: row.get('listSubject').value, + }; + }); } } + + this.formTargetClass = formTargetClass; + this.fields = fields; + + return true; + } + + solidUiTypeToWidget(type) { + switch (type) { + case 'http://www.w3.org/ns/ui#SingleLineTextField': + return 'string'; + case 'http://www.w3.org/ns/ui#MultiLineTextField': + return 'textarea'; + case 'http://www.w3.org/ns/ui#Choice': + return 'dropdown'; + case 'http://www.w3.org/ns/ui#BooleanField': + return 'checkbox'; + case 'http://www.w3.org/ns/ui#DateField': + return 'date'; + } + } + + shaclTypeToWidget(type) { + switch (type) { + case 'http://www.w3.org/2001/XMLSchema#string': + return 'string'; + case 'http://www.w3.org/ns/shacl#IRI': + return 'dropdown'; + case 'http://www.w3.org/2001/XMLSchema#boolean': + return 'checkbox'; + case 'http://www.w3.org/2001/XMLSchema#date': + return 'date'; + } + } + + async updateFields() { + const fields = this.fields; + this.fields = []; + return new Promise((resolve) => + setTimeout(() => { + this.fields = fields; + resolve(); + }, 0) + ); + } + + async updatePolicies() { + const policies = this.policies; + this.policies = []; + return new Promise((resolve) => + setTimeout(() => { + this.policies = policies; + resolve(); + }, 0) + ); } addIfNotIncluded(prefixes, prefix, url) { @@ -335,7 +579,7 @@ export default class IndexRoute extends Route { } }); if (!alreadyIncluded) { - prefixes.push(`@prefix ${prefix}: <${url}> .`); + prefixes.push(`@prefix ${prefix}: <${url}>.`); } return prefixes; } diff --git a/app/services/solid-auth.js b/app/services/solid-auth.js new file mode 100644 index 0000000..1b4a1ce --- /dev/null +++ b/app/services/solid-auth.js @@ -0,0 +1,17 @@ +import Service from '@ember/service'; +import { handleIncomingRedirect } from '@smessie/solid-client-authn-browser'; +import { tracked } from '@glimmer/tracking'; + +export default class SolidAuthService extends Service { + @tracked loggedIn; + + async restoreSession() { + // Restore solid session + const info = await handleIncomingRedirect({ + url: window.location.href, + restorePreviousSession: true, + }); + this.loggedIn = info.webId; + console.log('Logged in as ', info.webId, info); + } +} diff --git a/app/styles/app.css b/app/styles/app.css index a73b421..5f86e10 100644 --- a/app/styles/app.css +++ b/app/styles/app.css @@ -21,7 +21,3 @@ float: right; margin-top: -1.5em; } - -.logout-btn { - margin-left: 0.5em; -} diff --git a/app/templates/index.hbs b/app/templates/index.hbs index b03f140..8f6d1b4 100644 --- a/app/templates/index.hbs +++ b/app/templates/index.hbs @@ -1,18 +1,42 @@ {{page-title "Index"}} -Fork me on GitHub +Fork me on GitHub

Form Generator

- +
+
+ +
+
+
Authentication
+ {{#if this.solidAuth.loggedIn}} +

You are authenticated as {{this.solidAuth.loggedIn}}

+ + {{else}} + + {{#if this.authError }} + {{ this.authError }}
+ {{/if}} + + {{/if}}
@@ -24,73 +48,112 @@
When loading a form resource, any changes will be discarded and the given URI will be loaded. -

Beware! This is only a proof of concepts, not all field types by the vocabularies are supported.

+

Beware! This is only a proof of concepts, not all field types by the + vocabularies are supported.

- {{#if this.success}} + {{#if this.model.success}}
- {{this.success}} + {{this.model.success}}
{{/if}} - {{#if this.error}} + {{#if this.model.error}}
- {{this.error}} + {{this.model.error}}
{{/if}} + {{#if this.model.info}} +
+ {{this.model.info}} + +
+ {{/if}}
-
-
-
- Available form fields -
-
Drag and drop them in your form on the right
- -
-
- - Input String +
+
+
+
Available policies
+
Drag and drop them in your policies on the right
+ +
+
+ + HTTP Request +
-
- - {{#if this.isShaclVocabulary }} - {{ else }} - + +
- - Textarea + + Redirect
- {{/if}} - -
-
- - Select Dropdown + +
+
+ + N3 Patch +
-
- - -
-
- - Date + +
+
+
+
+
+ Available form fields +
+
Drag and drop them in your form on the right
+ +
+
+ + Input String +
-
- - -
-
- - Checkbox + + {{#if (this.isEqual this.model.vocabulary "http://www.w3.org/ns/shacl#") }} + {{ else }} + +
+
+ + Textarea +
+
+
+ {{/if}} + +
+
+ + Select Dropdown +
-
- + + +
+
+ + Date +
+
+
+ +
+
+ + Checkbox +
+
+
+
@@ -101,100 +164,113 @@
Drag and drop the form fields in this area to reorder them
-
- - {{#if this.model.form.error }} - {{ this.model.form.error }} + + {{#if this.model.formTargetClassError }} + {{ this.model.formTargetClassError }} {{/if}}

-
Fill in the details below about where the data should be stored when someone fill in the form and click submit.
-
- -
- - {{#if this.policyTypeError }} - {{ this.policyTypeError }} - {{/if}} -
-
+ {{#each this.model.policies as |policy|}} -
- -
- - {{#if this.policyURLError }} - {{ this.policyURLError }} - {{/if}} -
-
+
+
+
+ {{#if (this.isEqual policy.executionTarget "http://example.org/httpRequest") }} +
+ + HTTP Request +
+ {{else if (this.isEqual policy.executionTarget "http://example.org/redirect") }} +
+ + Redirect +
+ {{else if (this.isEqual policy.executionTarget "http://example.org/n3Patch") }} +
+ + N3 Patch +
+ {{/if}} +
+ +
+
-
- -
- - {{#if this.policyMethodError }} - {{ this.policyMethodError }} - {{/if}} -
-
+
+ +
+ + {{#if policy.urlError }} + {{ policy.urlError }} + {{/if}} +
+
-
- -
- - {{#if this.policyContentTypeError }} - {{ this.policyContentTypeError }} - {{/if}} + {{#if (this.isEqual policy.executionTarget "http://example.org/httpRequest") }} +
+ +
+ + {{#if policy.methodError }} + {{ policy.methodError }} + {{/if}} +
+
+
+ +
+ + {{#if policy.contentTypeError }} + {{ policy.contentTypeError }} + {{/if}} +
+
+ {{/if}} +
-
-
+ {{/each}} -
Fill in the URL to which the user should be sent after submitting the form.
-
- -
- + +
+
+ Drag here to add policy +
-
+
- - {{#each this.fields as |field|}} + {{#each this.model.fields as |field|}}
@@ -237,15 +313,15 @@
+ aria-describedby="label-label-{{field.uuid}}" />
- {{#if field.error }} @@ -261,11 +337,11 @@ + aria-describedby="label-placeholder-{{field.uuid}}" />
{{/if}} - {{#if this.isShaclVocabulary }} + {{#if (this.isEqual this.model.vocabulary "http://www.w3.org/ns/shacl#") }}
Min count - {{#if field.choice.error }} - {{ field.choice.error }} + aria-describedby="choice-label-binding-{{field.uuid}}" /> + {{#if field.choiceError }} + {{ field.choiceError }} {{/if}}
@@ -312,8 +388,8 @@
-