/* F O R M S * * A Vanilla Dom implementation of the form language */ /* eslint-disable multiline-ternary */ /* global alert */ import * as buttons from './buttons' import { fieldParams } from './forms/fieldParams' import { field, mostSpecificClassURI, fieldFunction } from './forms/fieldFunction' import { setFieldStyle } from './forms/formStyle' import * as debug from '../debug' import { errorMessageBlock } from './error' import { basicField, fieldLabel, fieldStore, renderNameValuePair } from './forms/basic' import { autocompleteField } from './forms/autocomplete/autocompleteField' import * as style from '../style' import { icons } from '../iconBase' import * as log from '../log' import * as ns from '../ns' import * as $rdf from 'rdflib' import { store } from 'solid-logic' import * as utils from '../utils' import { IconicMultiSelect } from './multiSelect' import * as widgets from '../widgets' export { basicField, fieldLabel, fieldStore, renderNameValuePair } from './forms/basic' // Note default export export { field, mostSpecificClassURI, fieldFunction } from './forms/fieldFunction' export { fieldParams } from './forms/fieldParams' const checkMarkCharacter = '\u2713' const cancelCharacter = '\u2715' const dashCharacter = '-' const kb = store field[ns.ui('AutocompleteField').uri] = autocompleteField // /////////////////////////////////////////////////////////////////////// /* Form Field implementations ** */ /** Group of different fields ** ** One type of form field is an ordered Group of other fields. ** A Form is actually just the same as a group. ** ** @param {Document} dom The HTML Document object aka Document Object Model ** @param {Element?} container If present, the created widget will be appended to this ** @param {Map} already A hash table of (form, subject) kept to prevent recursive forms looping ** @param {Node} subject The thing about which the form displays/edits data ** @param {Node} form The form or field to be rendered ** @param {Node} dataDoc The web document in which the data is ** @param {function(ok, errorMessage)} callbackFunction Called when data is changed? ** ** @returns {Element} The HTML widget created */ function refreshOpionsSubfieldinGroup (dom, already, subject, dataDoc, callbackFunction, groupDiv, subfields) { const eles = groupDiv.children for (let j = 0; j < subfields.length; j++) { // This is really messy. const field = subfields[j] const t = mostSpecificClassURI(field) // Field type if (t === ns.ui('Options').uri) { const optionsRender = fieldFunction(dom, field) const newOne = optionsRender( dom, null, already, subject, field, dataDoc, callbackFunction ) debug.log('Refreshing Options field by replacing it.') // better to support actual refresh groupDiv.insertBefore(newOne, eles[j]) groupDiv.removeChild(eles[j + 1]) // Remove the old one } } } field[ns.ui('Form').uri] = field[ns.ui('Group').uri] = function (dom, container, already, subject, form, dataDoc, callbackFunction) { const box = dom.createElement('div') const ui = ns.ui if (container) container.appendChild(box) // Prevent loops if (!form) return const key = subject.toNT() + '|' + form.toNT() if (already[key]) { // been there done that box.appendChild(dom.createTextNode('Group: see above ' + key)) // TODO fix dependency cycle to solid-panes by calling outlineManager // const plist = [$rdf.st(subject, ns.owl('sameAs'), subject)] // @@ need prev subject // dom.outlineManager.appendPropertyTRs(box, plist) // dom.appendChild(plist) return box } const already2 = {} for (const x in already) already2[x] = 1 already2[key] = 1 const formDoc = form.doc ? form.doc() : null // @@ if blank no way to know const weight0 = kb.any(form, ui('weight'), null, formDoc) // Say 0-3 const weight = weight0 ? Number(weight0.value) : 1 if (weight > 3 || weight < 0) return box.appendChild(errorMessageBlock(dom, `Form Group weight ${weight} should be 0-3`)) box.setAttribute('style', style.formGroupStyle[weight]) // Indent a group box.style.display = 'flex' box.style.flexDirection = 'column' box.class = 'form-weight-' + weight let parts = kb.any(form, ui('parts'), null, formDoc) let subfields if (parts) { subfields = parts.elements } else { parts = kb.each(form, ui('part'), null, formDoc) // Warning: unordered subfields = sortBySequence(parts) } if (!parts) { return box.appendChild(errorMessageBlock(dom, 'No parts to form! ')) } for (let i = 0; i < subfields.length; i++) { const field = subfields[i] const subFieldFunction = fieldFunction(dom, field) // const itemChanged = function (ok, body) { if (ok && body && body.widget && body.widget === 'select') { refreshOpionsSubfieldinGroup(dom, already, subject, dataDoc, callbackFunction, box, subfields) } callbackFunction(ok, { widget: 'group', change: body }) } box.appendChild(subFieldFunction(dom, null, already2, subject, field, dataDoc, itemChanged)) } return box } /** Options field: Select one or more cases ** ** @param {Document} dom The HTML Document object aka Document Object Model ** @param {Element?} container If present, the created widget will be appended to this ** @param {Map} already A hash table of (form, subject) kept to prevent recursive forms looping ** @param {Node} subject The thing about which the form displays/edits data ** @param {Node} form The form or field to be rendered ** @param {Node} dataDoc The web document in which the data is ** @param {function(ok, errorMessage)} callbackFunction Called when data is changed? ** ** @returns {Element} The HTML widget created */ field[ns.ui('Options').uri] = function ( dom, container, already, subject, form, dataDoc, callbackFunction ) { const kb = store const box = dom.createElement('div') const formDoc = form.doc ? form.doc() : null // @@ if blank no way to know const ui = ns.ui if (container) container.appendChild(box) let dependingOn = kb.any(form, ui('dependingOn')) if (!dependingOn) { dependingOn = ns.rdf('type') } // @@ default to type (do we want defaults?) const cases = kb.each(form, ui('case'), null, formDoc) if (!cases) { box.appendChild(errorMessageBlock(dom, 'No cases to Options form. ')) } let values if (dependingOn.sameTerm(ns.rdf('type'))) { values = Object.keys(kb.findTypeURIs(subject)).map(uri => $rdf.sym(uri)) // Use RDF-S inference } else { values = kb.each(subject, dependingOn) } for (let i = 0; i < cases.length; i++) { const c = cases[i] const tests = kb.each(c, ui('for'), null, formDoc) // There can be multiple 'for' let match = false for (let j = 0; j < tests.length; j++) { for (const value of values) { const test = tests[j] if (value.sameTerm(tests) || (value.termType === test.termType && value.value === test.value)) { match = true } } } if (match) { const field = kb.the(c, ui('use')) if (!field) { box.appendChild( errorMessageBlock( dom, 'No "use" part for case in form ' + form ) ) return box } else { appendForm( dom, box, already, subject, field, dataDoc, callbackFunction ) } break } } // @@ Add box.refresh() to sync fields with values return box } /** Multiple field: zero or more similar subFields ** ** @param {Document} dom The HTML Document object aka Document Object Model ** @param {Element?} container If present, the created widget will be appended to this ** @param {Map} already A hash table of (form, subject) kept to prevent recursive forms looping ** @param {Node} subject The thing about which the form displays/edits data ** @param {Node} form The form or field to be rendered ** @param {Node} dataDoc The web document in which the data is ** @param {function(ok, errorMessage)} callbackFunction Called when data is changed? ** ** @returns {Element} The HTML widget created ** ** Form properties: ** @param {Boolean} reverse Make e reverse arc in the data OPS not SPO ** @param {NamedNode} property The property to be written in the data ** @param {Boolean} ordered Is the list an ordered one where the user defined the order */ field[ns.ui('Multiple').uri] = function ( dom, container, already, subject, form, dataDoc, callbackFunction ) { /** Diagnostic function */ function debugString (values) { return values.map(x => x.toString().slice(-7)).join(', ') } /** Add an item to the local quadstore not the UI or the web * * @param {Node} object The RDF object to be represented by this item. */ async function addItem () { const object = newThing(dataDoc) // by default just add new nodes if (ordered) { createListIfNecessary() // Sets list and unsavedList list.elements.push(object) await saveListThenRefresh() } else { // eslint-disable-next-line multiline-ternary const toBeInserted = reverse ? [$rdf.st(object, property, subject, dataDoc)] : [$rdf.st(subject, property, object, dataDoc)] try { await kb.updater.update([], toBeInserted) } catch (err) { const msg = 'Error adding to unordered multiple: ' + err box.appendChild(errorMessageBlock(dom, msg)) debug.error(msg) } refresh() } } /** Make a dom representation for an item * @param {Event} anyEvent if used as an event handler * @param {Node} object The RDF object to be represented by this item. */ function renderItem (object) { async function deleteThisItem () { if (ordered) { debug.log('pre delete: ' + debugString(list.elements)) for (let i = 0; i < list.elements.length; i++) { if (list.elements[i].sameTerm(object)) { list.elements.splice(i, 1) await saveListThenRefresh() return } } } else { // unordered if (kb.holds(subject, property, object, dataDoc)) { const del = [$rdf.st(subject, property, object, dataDoc)] kb.updater.update(del, [], function (uri, ok, message) { if (ok) { body.removeChild(subField) } else { body.appendChild( errorMessageBlock( dom, 'Multiple: delete failed: ' + message ) ) } }) } } } /** Move the object up or down in the ordered list * @param {Event} anyEvent if used as an event handler * @param {Boolean} upwards Move this up (true) or down (false). */ async function moveThisItem (event, upwards) { // @@ possibly, allow shift+click to do move to top or bottom? debug.log('pre move: ' + debugString(list.elements)) let i for (i = 0; i < list.elements.length; i++) { // Find object in array if (list.elements[i].sameTerm(object)) { break } } if (i === list.elements.length) { alert('list move: not found element for ' + object) } if (upwards) { if (i === 0) { alert('@@ boop - already at top -temp message') // @@ make boop sound return } list.elements.splice(i - 1, 2, list.elements[i], list.elements[i - 1]) } else { // downwards if (i === list.elements.length - 1) { alert('@@ boop - already at bottom -temp message') // @@ make boop sound return } list.elements.splice(i, 2, list.elements[i + 1], list.elements[i]) } await saveListThenRefresh() } /* A subField has been filled in * * One possibility is to not actually make the link to the thing until * this callback happens to avoid widow links */ function itemDone (ok, message) { debug.log(`Item done callback for item ${object.toString()}`) if (!ok) { // when does this happen? errors typically deal with upstream debug.error(' Item done callback: Error: ' + message) } callbackFunction(ok, message) } log.debug('Multiple: render object: ' + object) const fn = fieldFunction(dom, element) const subField = fn(dom, null, already, object, element, dataDoc, itemDone) // subfields was: body. moving to not passing that subField.subject = object // Keep a back pointer between the DOM array and the RDF objects // delete button and move buttons if (kb.updater.editable(dataDoc.uri)) { buttons.deleteButtonWithCheck(dom, subField, multipleUIlabel, deleteThisItem) if (ordered) { // Add controsl in a frame const frame = dom.createElement('div') frame.style.display = 'grid' frame.style.gridTemplateColumns = 'auto 3em' frame.style.gridTemplateRows = '50% 50%' const moveUpButton = buttons.button( dom, icons.iconBase + 'noun_1369237.svg', 'Move Up', async event => moveThisItem(event, true)) const moveDownButton = buttons.button( dom, icons.iconBase + 'noun_1369241.svg', 'Move Down', async event => moveThisItem(event, false)) const shim = dom.createElement('div') shim.appendChild(subField) // Subfield has its own layout frame.appendChild(shim) frame.appendChild(moveUpButton) frame.appendChild(moveDownButton) moveUpButton.style.gridColumn = 2 moveDownButton.style.gridColumn = 2 moveUpButton.style.gridRow = 1 moveDownButton.style.padding = '0em' // don't take too much space moveUpButton.style.padding = '0em' moveDownButton.style.gridRow = 2 shim.style.gridColumn = 1 shim.style.gridRowStart = 'span 2' // Cover both rows // shim.style.gridRowEnd = 2 // Cover both rows return frame } } return subField // unused } // renderItem /// ///////// Body of Multiple form field implementation const plusIconURI = icons.iconBase + 'noun_19460_green.svg' // white plus in green circle const kb = store const formDoc = form.doc ? form.doc() : null // @@ if blank no way to know const box = (dom.createElement('div')) const shim = box // no shim // We don't indent multiple as it is a sort of a prefix of the next field and has contents of one. // box.setAttribute('style', 'padding-left: 2em; border: 0.05em solid green;') // Indent a multiple const ui = ns.ui if (container) container.appendChild(box) const orderedNode = kb.any(form, ui('ordered')) const ordered = orderedNode ? $rdf.Node.toJS(orderedNode) : false const property = kb.any(form, ui('property')) const reverse = kb.anyJS(form, ui('reverse'), null, formDoc) if (!property) { box.appendChild( errorMessageBlock(dom, 'No property to multiple: ' + form) ) // used for arcs in the data return shim } let multipleUIlabel = kb.any(form, ui('label')) if (!multipleUIlabel) multipleUIlabel = utils.label(property) let min = kb.any(form, ui('min')) // This is the minimum number -- default 0 min = min ? 0 + min.value : 0 const element = kb.any(form, ui('part')) // This is the form to use for each one if (!element) { box.appendChild( errorMessageBlock(dom, 'No part to multiple: ' + form) ) return shim } const body = box.appendChild(dom.createElement('div')) body.style.display = 'flex' body.style.flexDirection = 'column' let list // The RDF collection which keeps the ordered version or null let values // Initial values - always an array. Even when no list yet. values = reverse ? kb.any(null, property, subject, dataDoc) : kb.any(subject, property, null, dataDoc) if (ordered) { list = reverse ? kb.any(null, property, subject, dataDoc) : kb.any(subject, property, null, dataDoc) if (list) { values = list.elements } else { values = [] } } else { values = reverse ? kb.each(null, property, subject, dataDoc) : kb.each(subject, property, null, dataDoc) list = null } // Add control on the bottom for adding more items if (kb.updater.editable(dataDoc.uri)) { const tail = box.appendChild(dom.createElement('div')) tail.style.padding = '0.5em' const img = tail.appendChild(dom.createElement('img')) img.setAttribute('src', plusIconURI) // plus sign img.setAttribute('style', 'margin: 0.2em; width: 1.5em; height:1.5em') img.title = 'Click to add another ' + multipleUIlabel const prompt = dom.createElement('span') prompt.textContent = (values.length === 0 ? 'Add another ' : 'Add ') + multipleUIlabel tail.addEventListener('click', async _eventNotUsed => { await addItem() }, true) tail.appendChild(prompt) } function createListIfNecessary () { if (!list) { list = new $rdf.Collection() if (reverse) { kb.add(list, property, subject, dataDoc) } else { kb.add(subject, property, list, dataDoc) } } } async function saveListThenRefresh () { debug.log('save list: ' + debugString(list.elements)) // 20191214 createListIfNecessary() try { await kb.fetcher.putBack(dataDoc) } catch (err) { box.appendChild( errorMessageBlock(dom, 'Error trying to put back a list: ' + err) ) return } refresh() } function refresh () { let vals if (ordered) { const li = reverse ? kb.the(null, property, subject, dataDoc) : kb.the(subject, property, null, dataDoc) vals = li ? li.elements : [] } else { vals = reverse ? kb.each(null, property, subject, dataDoc) : kb.each(subject, property, null, dataDoc) vals.sort() // achieve consistency on each refresh } utils.syncTableToArrayReOrdered(body, vals, renderItem) } body.refresh = refresh // Allow live update refresh() async function asyncStuff () { const extra = min - values.length if (extra > 0) { for (let j = 0; j < extra; j++) { debug.log('Adding extra: min ' + min) await addItem() // Add blanks if less than minimum } await saveListThenRefresh() } } asyncStuff().then( () => { debug.log(' Multiple render: async stuff ok') }, (err) => { debug.error(' Multiple render: async stuff fails. #### ', err) } ) // async return shim } // Multiple /* Text field ** */ // For possible date popups see e.g. http://www.dynamicdrive.com/dynamicindex7/jasoncalendar.htm // or use HTML5: http://www.w3.org/TR/2011/WD-html-markup-20110113/input.date.html // field[ns.ui('PhoneField').uri] = basicField field[ns.ui('EmailField').uri] = basicField field[ns.ui('ColorField').uri] = basicField field[ns.ui('DateField').uri] = basicField field[ns.ui('DateTimeField').uri] = basicField field[ns.ui('TimeField').uri] = basicField field[ns.ui('NumericField').uri] = basicField field[ns.ui('IntegerField').uri] = basicField field[ns.ui('DecimalField').uri] = basicField field[ns.ui('FloatField').uri] = basicField field[ns.ui('TextField').uri] = basicField field[ns.ui('SingleLineTextField').uri] = basicField field[ns.ui('NamedNodeURIField').uri] = basicField /* Multiline Text field ** */ field[ns.ui('MultiLineTextField').uri] = function ( dom, container, already, subject, form, dataDoc, callbackFunction ) { const ui = ns.ui const kb = store const formDoc = form.doc ? form.doc() : null // @@ if blank no way to know const property = kb.any(form, ui('property')) if (!property) { return errorMessageBlock(dom, 'No property to text field: ' + form) } const box = dom.createElement('div') box.style.display = 'flex' box.style.flexDirection = 'row' const left = box.appendChild(dom.createElement('div')) left.style.width = style.formFieldNameBoxWidth const right = box.appendChild(dom.createElement('div')) left.appendChild(fieldLabel(dom, property, form)) dataDoc = fieldStore(subject, property, dataDoc) const text = kb.anyJS(subject, property, null, dataDoc) || '' const editable = kb.updater.editable(dataDoc.uri) const suppressEmptyUneditable = form && kb.anyJS(form, ns.ui('suppressEmptyUneditable'), null, formDoc) if (!editable && suppressEmptyUneditable && text === '') { box.style.display = 'none' } const field = makeDescription( dom, kb, subject, property, dataDoc, callbackFunction ) right.appendChild(field) if (container) container.appendChild(box) return box } /* Boolean field and Tri-state version (true/false/null) ** ** @@ todo: remove tristate param */ function booleanField ( dom, container, already, subject, form, dataDoc, callbackFunction, tristate ) { const ui = ns.ui const kb = store const property = kb.any(form, ui('property')) if (!property) { const errorBlock = errorMessageBlock( dom, 'No property to boolean field: ' + form ) if (container) container.appendChild(errorBlock) return errorBlock } let lab = kb.any(form, ui('label')) if (!lab) lab = utils.label(property, true) // Init capital dataDoc = fieldStore(subject, property, dataDoc) let state = kb.any(subject, property) if (state === undefined) { state = false } // @@ sure we want that -- or three-state? const ins = $rdf.st(subject, property, true, dataDoc) const del = $rdf.st(subject, property, false, dataDoc) const box = buildCheckboxForm(dom, kb, lab, del, ins, form, dataDoc, tristate) if (container) container.appendChild(box) return box } field[ns.ui('BooleanField').uri] = function ( dom, container, already, subject, form, dataDoc, callbackFunction ) { return booleanField( dom, container, already, subject, form, dataDoc, callbackFunction, false ) } field[ns.ui('TristateField').uri] = function ( dom, container, already, subject, form, dataDoc, callbackFunction ) { return booleanField( dom, container, already, subject, form, dataDoc, callbackFunction, true ) } /* Classifier field ** ** Nested categories ** ** @@ To do: If a classification changes, then change any dependent Options fields. */ field[ns.ui('Classifier').uri] = function ( dom, container, already, subject, form, dataDoc, callbackFunction ) { const kb = store const ui = ns.ui const category = kb.any(form, ui('category')) if (!category) { return errorMessageBlock(dom, 'No category for classifier: ' + form) } log.debug('Classifier: dataDoc=' + dataDoc) const checkOptions = function (ok, body) { if (!ok) return callbackFunction(ok, body) return callbackFunction(ok, body) } const box = makeSelectForNestedCategory( dom, kb, subject, category, dataDoc, checkOptions ) if (container) container.appendChild(box) return box } /** Choice field ** ** Not nested. Generates a link to something from a given class. ** Optional subform for the thing selected. ** Generates a subForm based on a ui:use form ** Will look like: **