diff --git a/src/electron/frontend/core/components/JSONSchemaInput.js b/src/electron/frontend/core/components/JSONSchemaInput.js index acf4f3906a..5e65737232 100644 --- a/src/electron/frontend/core/components/JSONSchemaInput.js +++ b/src/electron/frontend/core/components/JSONSchemaInput.js @@ -1,1311 +1,1310 @@ -import { LitElement, css, html } from "lit"; -import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { FilesystemSelector } from "./FileSystemSelector"; - -import { BasicTable } from "./BasicTable"; -import { header, tempPropertyKey, tempPropertyValueKey } from "./forms/utils"; - -import { Button } from "./Button"; -import { List } from "./List"; -import { Modal } from "./Modal"; - -import { capitalize } from "./forms/utils"; -import { JSONSchemaForm, getIgnore } from "./JSONSchemaForm"; -import { Search } from "./Search"; -import tippy from "tippy.js"; -import { merge } from "./pages/utils"; -import { OptionalSection } from "./OptionalSection"; -import { InspectorListItem } from "./preview/inspector/InspectorList"; - -const isDevelopment = !!import.meta.env; - -const dateTimeRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/; - -function resolveDateTime(value) { - if (typeof value === "string") { - const match = value.match(dateTimeRegex); - if (match) return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`; - return value; - } - - return value; -} - -export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { - const name = fullPath.slice(-1)[0]; - const path = fullPath.slice(0, -1); - const relativePath = this.form?.base ? fullPath.slice(this.form.base.length) : fullPath; - - const schema = this.schema; - const validateOnChange = this.validateOnChange; - - const ignore = this.form?.ignore ? getIgnore(this.form?.ignore, path) : {}; - - const commonValidationFunction = async (tableBasePath, path, parent, newValue, itemPropSchema) => { - const warnings = []; - const errors = []; - - const name = path.slice(-1)[0]; - const completePath = [...tableBasePath, ...path.slice(0, -1)]; - - const result = await (validateOnChange - ? this.onValidate - ? this.onValidate() - : this.form?.triggerValidation - ? this.form.triggerValidation( - name, - completePath, - false, - this, - itemPropSchema, - { ...parent, [name]: newValue }, - { - onError: (error) => { - errors.push(error); // Skip counting errors - }, - onWarning: (warning) => { - warnings.push(warning); // Skip counting warnings - }, - } - ) // NOTE: No pattern properties support - : "" - : true); - - const returnedValue = errors.length ? errors : warnings.length ? warnings : result; - - return returnedValue; - }; - - const commonTableMetadata = { - onStatusChange: () => this.form?.checkStatus && this.form.checkStatus(), // Check status on all elements - validateEmptyCells: this.validateEmptyValue, - deferLoading: this.form?.deferLoading, - onLoaded: () => { - if (this.form) { - if (this.form.nLoaded) this.form.nLoaded++; - if (this.form.checkAllLoaded) this.form.checkAllLoaded(); - } - }, - onThrow: (...args) => onThrow(...args), - }; - - const addPropertyKeyToSchema = (schema) => { - const schemaCopy = structuredClone(schema); - - const schemaItemsRef = schemaCopy["items"]; - - if (!schemaItemsRef.properties) schemaItemsRef.properties = {}; - if (!schemaItemsRef.required) schemaItemsRef.required = []; - - schemaItemsRef.properties[tempPropertyKey] = { title: "Property Key", type: "string", pattern: name }; - if (!schemaItemsRef.order) schemaItemsRef.order = []; - schemaItemsRef.order.unshift(tempPropertyKey); - - schemaItemsRef.required.push(tempPropertyKey); - - return schemaCopy; - }; - - const createNestedTable = (id, value, { name: propName = id, nestedSchema = schema } = {}) => { - const schemaCopy = addPropertyKeyToSchema(nestedSchema); - - const resultPath = [...path]; - - const schemaPath = [...fullPath]; - - // THIS IS AN ISSUE - const rowData = Object.entries(value).map(([key, value]) => { - return !schemaCopy["items"] - ? { [tempPropertyKey]: key, [tempPropertyValueKey]: value } - : { [tempPropertyKey]: key, ...value }; - }); - - if (propName) { - resultPath.push(propName); - schemaPath.push(propName); - } - - const allRemovedKeys = new Set(); - - const keyAlreadyExists = (key) => Object.keys(value).includes(key); - - const previousValidValues = {}; - - function resolvePath(path, target) { - return path - .map((key, i) => { - const ogKey = key; - const nextKey = path[i + 1]; - if (key === tempPropertyKey) key = target[tempPropertyKey]; - if (nextKey === tempPropertyKey) key = []; - - target = target[ogKey] ?? {}; - - if (nextKey === tempPropertyValueKey) return target[tempPropertyKey]; // Grab next property key - if (key === tempPropertyValueKey) return []; - - return key; - }) - .flat(); - } - - function setValueOnAccumulator(row, acc) { - const key = row[tempPropertyKey]; - - if (!key) return acc; - - if (tempPropertyValueKey in row) { - const propValue = row[tempPropertyValueKey]; - if (Array.isArray(propValue)) - acc[key] = propValue.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); - else acc[key] = propValue; - } else { - const copy = { ...row }; - delete copy[tempPropertyKey]; - acc[key] = copy; - } - - return acc; - } - - const nestedIgnore = this.form?.ignore ? getIgnore(this.form?.ignore, schemaPath) : {}; - - merge(overrides.ignore, nestedIgnore); - - merge(overrides.schema, schemaCopy, { arrays: "append" }); - - const tableMetadata = { - keyColumn: tempPropertyKey, - schema: schemaCopy, - data: rowData, - ignore: nestedIgnore, // According to schema - - onUpdate: function (path, newValue) { - const oldKeys = Object.keys(value); - - if (path.slice(-1)[0] === tempPropertyKey && keyAlreadyExists(newValue)) return; // Do not overwrite existing keys - - const result = this.data.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); - - const newKeys = Object.keys(result); - const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); - removedKeys.forEach((key) => allRemovedKeys.add(key)); - newKeys.forEach((key) => allRemovedKeys.delete(key)); - allRemovedKeys.forEach((key) => (result[key] = undefined)); - - // const resolvedPath = resolvePath(path, this.data) - return onUpdate.call(this, [], result); // Update all table data - }, - - validateOnChange: function (path, parent, newValue) { - const rowIdx = path[0]; - const currentKey = this.data[rowIdx]?.[tempPropertyKey]; - - const updatedPath = resolvePath(path, this.data); - - const resolvedKey = previousValidValues[rowIdx] ?? currentKey; - - // Do not overwrite existing keys - if (path.slice(-1)[0] === tempPropertyKey && resolvedKey !== newValue) { - if (keyAlreadyExists(newValue)) { - if (!previousValidValues[rowIdx]) previousValidValues[rowIdx] = resolvedKey; - - return [ - { - message: `Key already exists.
This value is still ${resolvedKey}.`, - type: "error", - }, - ]; - } else delete previousValidValues[rowIdx]; - } - - const toIterate = updatedPath.filter((value) => typeof value === "string"); - - const itemPropsSchema = toIterate.reduce( - (acc, key) => acc?.properties?.[key] ?? acc?.items?.properties?.[key], - schemaCopy - ); - - return commonValidationFunction([], updatedPath, parent, newValue, itemPropsSchema, 1); - }, - ...commonTableMetadata, - }; - - const table = this.renderTable(id, tableMetadata, fullPath); - - return table; // Try rendering as a nested table with a fake property key (otherwise use nested forms) - }; - - const schemaCopy = structuredClone(schema); - - // Possibly multiple tables - if (isEditableObject(schema, this.value)) { - // One table with nested tables for each property - const data = getEditableItems(this.value, this.pattern, { name, schema: schemaCopy }).reduce( - (acc, { key, value }) => { - acc[key] = value; - return acc; - }, - {} - ); - - const table = createNestedTable(name, data, { schema }); - if (table) return table; - } - - const nestedIgnore = getIgnore(ignore, fullPath); - Object.assign(nestedIgnore, overrides.ignore ?? {}); - - merge(overrides.ignore, nestedIgnore); - - merge(overrides.schema, schemaCopy, { arrays: "append" }); - - // Normal table parsing - const tableMetadata = { - schema: schemaCopy, - data: this.value, - - ignore: nestedIgnore, // According to schema - - onUpdate: function () { - return onUpdate.call(this, relativePath, this.data); // Update all table data - }, - - validateOnChange: (...args) => commonValidationFunction(relativePath, ...args), - - ...commonTableMetadata, - }; - - const table = (this.table = this.renderTable(name, tableMetadata, path)); // Try creating table. Otherwise use nested form - - if (table) { - const tableEl = table === true ? new BasicTable(tableMetadata) : table; - const tables = this.form?.tables; - if (tables) tables[name] = tableEl; - return tableEl; - } -} - -// Schema or value indicates editable object -export const isEditableObject = (schema, value) => - schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value)); - -export const isAdditionalProperties = (pattern) => pattern === "additional"; -export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern); - -export const getEditableItems = (value = {}, pattern, { name, schema } = {}) => { - let items = Object.entries(value); - - const allowAdditionalProperties = isAdditionalProperties(pattern); - - if (isPatternProperties(pattern)) { - const regex = new RegExp(name); - items = items.filter(([key]) => regex.test(key)); - } else if (allowAdditionalProperties) { - const props = Object.keys(schema.properties ?? {}); - items = items.filter(([key]) => !props.includes(key)); - - const patternProps = Object.keys(schema.patternProperties ?? {}); - patternProps.forEach((key) => { - const regex = new RegExp(key); - items = items.filter(([k]) => !regex.test(k)); - }); - } else if (schema.properties) items = items.filter(([key]) => key in schema.properties); - - items = items.filter(([key]) => !key.includes("__")); // Remove secret properties - - return items.map(([key, value]) => { - return { key, value }; - }); -}; - -const isFilesystemSelector = (name = "", format) => { - if (Array.isArray(format)) return format.map((f) => isFilesystemSelector(name, f)).every(Boolean) ? format : null; - - const matched = name.match(/(.+_)?(.+)_paths?/); - if (!format && matched) format = matched[2] === "folder" ? "directory" : matched[2]; - return ["file", "directory"].includes(format) ? format : null; // Handle file and directory formats -}; - -function getFirstFocusableElement(element) { - const root = element.shadowRoot || element; - const focusableElements = getKeyboardFocusableElements(root); - if (focusableElements.length === 0) { - for (let child of root.children) { - const focusableElement = getFirstFocusableElement(child); - if (focusableElement) return focusableElement; - } - } - return focusableElements[0]; -} - -function getKeyboardFocusableElements(element = document) { - const root = element.shadowRoot || element; - return [ - ...root.querySelectorAll('a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'), - ].filter( - (focusableElement) => - !focusableElement.hasAttribute("disabled") && !focusableElement.getAttribute("aria-hidden") - ); -} - -export class JSONSchemaInput extends LitElement { - static get styles() { - return css` - * { - box-sizing: border-box; - } - - :host(.invalid) .guided--input { - background: rgb(255, 229, 228) !important; - } - - jsonschema-input { - width: 100%; - } - - main { - display: flex; - align-items: center; - } - - #controls { - margin-left: 10px; - flex-grow: 1; - } - - .guided--input { - width: 100%; - border-radius: 4px; - padding: 10px 12px; - font-size: 100%; - font-weight: normal; - border: 1px solid var(--color-border); - transition: border-color 150ms ease-in-out 0s; - outline: none; - color: rgb(33, 49, 60); - background-color: rgb(255, 255, 255); - } - - .guided--input:disabled { - opacity: 0.5; - pointer-events: none; - } - - .guided--input::placeholder { - opacity: 0.5; - } - - .guided--text-area { - height: 5em; - resize: none; - font-family: unset; - } - .guided--text-area-tall { - height: 15em; - } - .guided--input:hover { - box-shadow: rgb(231 238 236) 0px 0px 0px 2px; - } - .guided--input:focus { - outline: 0; - box-shadow: var(--color-light-green) 0px 0px 0px 1px; - } - - input[type="number"].hideStep::-webkit-outer-spin-button, - input[type="number"].hideStep::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - /* Firefox */ - input[type="number"].hideStep { - -moz-appearance: textfield; - } - - .guided--text-input-instructions { - font-size: 13px; - width: 100%; - padding-top: 4px; - color: dimgray !important; - margin: 0 0; - line-height: 1.4285em; - } - - .nan-handler { - display: flex; - align-items: center; - margin-left: 5px; - white-space: nowrap; - } - - .nan-handler span { - margin-left: 5px; - font-size: 12px; - } - - .schema-input.list { - width: 100%; - } - - .guided--form-label { - display: block; - width: 100%; - margin: 0; - margin-bottom: 10px; - color: black; - font-weight: 600; - font-size: 1.2em !important; - } - - :host([data-table]) .guided--form-label { - margin-bottom: 0px; - } - - .guided--form-label.centered { - text-align: center; - } - - .guided--form-label.header { - font-size: 1.5em !important; - } - - .required label:after { - content: " *"; - color: #ff0033; - } - - :host(:not([validateemptyvalue])) .required label:after { - color: gray; - } - - .required.conditional label:after { - color: transparent; - } - - hr { - display: block; - height: 1px; - border: 0; - border-top: 1px solid #ccc; - padding: 0; - margin-bottom: 1em; - } - - select { - background: url("data:image/svg+xml,") - no-repeat; - background-position: calc(100% - 0.75rem) center !important; - -moz-appearance: none !important; - -webkit-appearance: none !important; - appearance: none !important; - padding-right: 2rem !important; - } - `; - } - - static get properties() { - return { - schema: { type: Object, reflect: false }, - validateEmptyValue: { type: Boolean, reflect: true }, - required: { type: Boolean, reflect: true }, - }; - } - - // Enforce dynamic required properties - attributeChangedCallback(key, _, latest) { - super.attributeChangedCallback(...arguments); - - const formSchema = this.form?.schema; - if (!formSchema) return; - - if (key === "required") { - const name = this.path.slice(-1)[0]; - - if (latest !== null && !this.conditional) { - const requirements = formSchema.required ?? (formSchema.required = []); - if (!requirements.includes(name)) requirements.push(name); - } - - // Remove requirement from form schema (and force if conditional requirement) - else { - const requirements = formSchema.required; - if (requirements && requirements.includes(name)) { - const idx = requirements.indexOf(name); - if (idx > -1) requirements.splice(idx, 1); - } - } - } - } - - // schema, - // parent, - // path, - // form, - // pattern - // showLabel - // description - controls = []; - // required; - validateOnChange = true; - - constructor(props = {}) { - super(); - Object.assign(this, props); - if (props.validateEmptyValue === false) this.validateEmptyValue = true; // False is treated as required but not triggered if empty - } - - // Print the default value of the schema if not caught - onUncaughtSchema = (schema) => { - // In development, show uncaught schemas - if (!isDevelopment) { - if (this.form) { - const inputContainer = this.form.shadowRoot.querySelector(`#${this.path.slice(-1)[0]}`); - inputContainer.style.display = "none"; - } - } - - if (schema.default) return `
${JSON.stringify(schema.default, null, 2)}
`; - - const error = new InspectorListItem({ - message: - "

Internal GUIDE Error

Cannot render this property because of a misformatted schema.", - }); - error.style.width = "100%"; - - return error; - }; - - // onUpdate = () => {} - // onValidate = () => {} - - updateData(value, forceValidate = false) { - if (!forceValidate) { - // Update the actual input element - const inputElement = this.getElement(); - if (!inputElement) return false; - - const hasList = inputElement.querySelector("nwb-list"); - - if (inputElement.type === "checkbox") inputElement.checked = value; - else if (hasList) - hasList.items = this.#mapToList({ value, hasList }); // NOTE: Make sure this is correct - else if (inputElement instanceof Search) inputElement.shadowRoot.querySelector("input").value = value; - else inputElement.value = value; - } - - const { path: fullPath } = this; - const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; - const name = path.splice(-1)[0]; - - this.#updateData(fullPath, value); - this.#triggerValidation(name, path); // NOTE: Is asynchronous - - return true; - } - - getElement = () => this.shadowRoot.querySelector(".schema-input"); - - #activateTimeoutValidation = (name, path, hooks) => { - this.#clearTimeoutValidation(); - this.#validationTimeout = setTimeout(() => { - this.onValidate - ? this.onValidate() - : this.form?.triggerValidation - ? this.form.triggerValidation(name, path, undefined, this, undefined, undefined, hooks) - : ""; - }, 1000); - }; - - #clearTimeoutValidation = () => { - if (this.#validationTimeout) clearTimeout(this.#validationTimeout); - }; - - #validationTimeout = null; - #updateData = (fullPath, value, forceUpdate, hooks = {}) => { - this.onUpdate - ? this.onUpdate(value) - : this.form?.updateData - ? this.form.updateData(fullPath, value, forceUpdate) - : ""; - - const path = [...fullPath]; - const name = path.splice(-1)[0]; - - this.value = value; // Update the latest value - - if (hooks.willTimeout !== false) this.#activateTimeoutValidation(name, path, hooks); - }; - - #triggerValidation = async (name, path) => { - this.#clearTimeoutValidation(); - return this.onValidate - ? this.onValidate() - : this.form?.triggerValidation - ? this.form.triggerValidation(name, path, undefined, this) - : ""; - }; - - updated() { - const inputElement = this.getElement(); - if (inputElement) inputElement.dispatchEvent(new Event("change")); - } - - render() { - const { schema } = this; - - const input = this.#render(); - - if (input === null) return null; // Hide rendering - - const description = this.description ?? schema.description; - - const descriptionHTML = description - ? html`

- ${unsafeHTML(capitalize(description))}${[".", "?", "!"].includes(description.slice(-1)[0]) ? "" : "."} -

` - : ""; - - return html` -
- ${this.showLabel - ? html`` - : ""} -
${input}${this.controls ? html`
${this.controls}
` : ""}
- ${descriptionHTML} -
- `; - } - - #onThrow = (...args) => (this.onThrow ? this.onThrow(...args) : this.form?.onThrow(...args)); - - #list; - #mapToList({ value = this.value, schema = this.schema, list } = {}) { - const { path: fullPath } = this; - const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; - const name = path.splice(-1)[0]; - - const canAddProperties = isEditableObject(this.schema, this.value); - - if (canAddProperties) { - const editable = getEditableItems(this.value, this.pattern, { name, schema }); - - return editable.map(({ key, value }) => { - return { - key, - value, - controls: [ - new Button({ - label: "Edit", - size: "small", - onClick: () => - this.#createModal({ - key, - schema: isAdditionalProperties(this.pattern) ? undefined : schema, - results: value, - list: list ?? this.#list, - }), - }), - ], - }; - }); - } else { - const resolved = value ?? []; - return resolved - ? resolved.map((value) => { - return { value }; - }) - : []; - } - } - - #modal; - - #createModal({ key, schema = {}, results, list, label } = {}) { - const schemaCopy = structuredClone(schema); - - const createNewObject = !results && (schemaCopy.type === "object" || schemaCopy.properties); - - // const schemaProperties = Object.keys(schema.properties ?? {}); - // const additionalProperties = Object.keys(results).filter((key) => !schemaProperties.includes(key)); - // // const additionalElement = html`Cannot edit additional properties (${additionalProperties}) at this time` - - const allowPatternProperties = isPatternProperties(this.pattern); - const allowAdditionalProperties = isAdditionalProperties(this.pattern); - const createNewPatternProperty = allowPatternProperties && createNewObject; - - // Add a property name entry to the schema - if (createNewPatternProperty) { - schemaCopy.properties = { - __: { title: "Property Name", type: "string", pattern: this.pattern }, - ...schemaCopy.properties, - }; - schemaCopy.required = [...(schemaCopy.required ?? []), "__"]; - } - - if (this.#modal) this.#modal.remove(); - - const submitButton = new Button({ - label: "Submit", - primary: true, - }); - - const isObject = schemaCopy.type === "object" || schemaCopy.properties; // NOTE: For formatted strings, this is not an object - - // NOTE: Will be replaced by single instances - let updateTarget = results ?? (isObject ? {} : undefined); - - submitButton.onClick = async () => { - await nestedModalElement.validate(); - - let value = updateTarget; - - if (schemaCopy?.format && schemaCopy.properties) { - let newValue = schemaCopy?.format; - for (let key in schemaCopy.properties) newValue = newValue.replace(`{${key}}`, value[key] ?? "").trim(); - value = newValue; - } - - // Skip if not unique - if (schemaCopy.uniqueItems && list.items.find((item) => item.value === value)) - return this.#modal.toggle(false); - - // Add to the list - if (createNewPatternProperty) { - const key = value.__; - delete value.__; - list.add({ key, value }); - } else list.add({ key, value }); - - this.#modal.toggle(false); - }; - - this.#modal = new Modal({ - header: label ? `${header(label)} Editor` : key ? header(key) : `Property Editor`, - footer: submitButton, - showCloseButton: createNewObject, - }); - - const div = document.createElement("div"); - div.style.padding = "25px"; - - const inputTitle = header(schemaCopy.title ?? label ?? "Value"); - - const nestedModalElement = isObject - ? new JSONSchemaForm({ - schema: schemaCopy, - results: updateTarget, - validateEmptyValues: false, - onUpdate: (internalPath, value) => { - if (!createNewObject) { - const path = [key, ...internalPath]; - this.#updateData(path, value, true); // Live updates - } - }, - renderTable: this.renderTable, - onThrow: this.#onThrow, - }) - : new JSONSchemaForm({ - schema: { - properties: { - [tempPropertyKey]: { - ...schemaCopy, - title: inputTitle, - }, - }, - required: [tempPropertyKey], - }, - validateEmptyValues: false, - results: updateTarget, - onUpdate: (_, value) => { - if (createNewObject) updateTarget[key] = value; - else updateTarget = value; - }, - // renderTable: this.renderTable, - // onThrow: this.#onThrow, - }); - - div.append(nestedModalElement); - - this.#modal.append(div); - - document.body.append(this.#modal); - - setTimeout(() => this.#modal.toggle(true)); - - return this.#modal; - } - - #getType = (value = this.value) => (Array.isArray(value) ? "array" : typeof value); - - #handleNextInput = (idx) => { - const next = Object.values(this.form.inputs)[idx]; - if (next) { - const firstFocusableElement = getFirstFocusableElement(next); - if (firstFocusableElement) { - if (firstFocusableElement.tagName === "BUTTON") return this.#handleNextInput(idx + 1); - firstFocusableElement.focus(); - } - } - }; - - #moveToNextInput = (ev) => { - if (ev.key === "Enter") { - ev.preventDefault(); - if (this.form?.inputs) { - const idx = Object.values(this.form.inputs).findIndex((input) => input === this); - this.#handleNextInput(idx + 1); - } - - ev.target.blur(); - } - }; - - #render() { - const { validateOnChange, schema, path: fullPath } = this; - - this.removeAttribute("data-table"); - - // Do your best to fill in missing schema values - if (!("type" in schema)) schema.type = this.#getType(); - - const resolvedFullPath = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; - const path = [...resolvedFullPath]; - const name = path.splice(-1)[0]; - - const isArray = schema.type === "array"; // Handle string (and related) formats / types - - const itemSchema = this.form?.getSchema ? this.form.getSchema("items", schema) : schema["items"]; - const isTable = itemSchema?.type === "object" && this.renderTable; - - const canAddProperties = isEditableObject(this.schema, this.value); - - if (this.renderCustomHTML) { - const custom = this.renderCustomHTML(name, schema, path, { - onUpdate: this.#updateData, - onThrow: this.#onThrow, - }); - - const renderEmpty = custom === null; - if (custom) return custom; - else if (renderEmpty) { - this.remove(); // Remove from DOM so that parent can be empty - return; - } - } - - // Handle file and directory formats - const createFilesystemSelector = (format) => { - const filesystemSelectorElement = new FilesystemSelector({ - type: format, - value: this.value, - accept: schema.accept, - onSelect: (paths = []) => { - const value = paths.length ? paths : undefined; - this.#updateData(fullPath, value); - }, - onChange: (filePath) => validateOnChange && this.#triggerValidation(name, path), - onThrow: (...args) => this.#onThrow(...args), - dialogOptions: this.form?.dialogOptions, - dialogType: this.form?.dialogType, - multiple: isArray, - }); - filesystemSelectorElement.classList.add("schema-input"); - return filesystemSelectorElement; - }; - - // Transform to single item if maxItems is 1 - if (isArray && schema.maxItems === 1 && !isTable) { - return new JSONSchemaInput({ - value: this.value?.[0], - schema: { - ...schema.items, - strict: schema.strict, - }, - path: fullPath, - validateEmptyValue: this.validateEmptyValue, - required: this.required, - validateOnChange: () => (validateOnChange ? this.#triggerValidation(name, path) : ""), - form: this.form, - onUpdate: (value) => this.#updateData(fullPath, [value]), - }); - } - - if (isArray || canAddProperties) { - // if ('value' in this && !Array.isArray(this.value)) this.value = [ this.value ] - - const allowPatternProperties = isPatternProperties(this.pattern); - const allowAdditionalProperties = isAdditionalProperties(this.pattern); - - // Provide default item types - if (isArray) { - const hasItemsRef = "items" in schema && "$ref" in schema.items; - if (!("items" in schema)) schema.items = {}; - if (!("type" in schema.items) && !hasItemsRef) { - // Guess the type of the first item - if (this.value) { - const itemToCheck = this.value[0]; - schema.items.type = itemToCheck ? this.#getType(itemToCheck) : "string"; - } - - // If no value, handle uncaught schema - else return this.onUncaughtSchema(schema); - } - } - - const fileSystemFormat = isFilesystemSelector(name, itemSchema?.format); - if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); - // Create tables if possible - else if (itemSchema?.type === "string" && !itemSchema.properties) { - const list = new List({ - items: this.value, - emptyMessage: "No items", - onChange: ({ items }) => { - this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); - if (validateOnChange) this.#triggerValidation(name, path); - }, - }); - - if (itemSchema.enum) { - const search = new Search({ - options: itemSchema.enum.map((v) => { - return { - key: v, - value: v, - label: itemSchema.enumLabels?.[v] ?? v, - keywords: itemSchema.enumKeywords?.[v], - description: itemSchema.enumDescriptions?.[v], - link: itemSchema.enumLinks?.[v], - }; - }), - value: this.value, - listMode: schema.strict === false ? "click" : "append", - showAllWhenEmpty: false, - onSelect: async ({ label, value }) => { - if (!value) return; - if (schema.uniqueItems && this.value && this.value.includes(value)) return; - list.add({ content: label, value }); - }, - }); - - search.style.height = "auto"; - return html`
${search}${list}
`; - } else { - const input = document.createElement("input"); - input.classList.add("guided--input"); - input.placeholder = "Provide an item for the list"; - - const submitButton = new Button({ - label: "Submit", - primary: true, - size: "small", - onClick: () => { - const value = input.value; - if (!value) return; - if (schema.uniqueItems && this.value && this.value.includes(value)) return; - list.add({ value }); - input.value = ""; - }, - }); - - input.addEventListener("keydown", (ev) => { - if (ev.key === "Enter") submitButton.onClick(); - }); - - return html`
validateOnChange && this.#triggerValidation(name, path)} - > -
${input}${submitButton}
- ${list} -
`; - } - } else if (isTable) { - const instanceThis = this; - - function updateFunction(path, value = this.data) { - return instanceThis.#updateData(path, value, true, { - willTimeout: false, // Since there is a special validation function, do not trigger a timeout validation call - onError: (e) => e, - onWarning: (e) => e, - }); - } - - const externalPath = this.form?.base ? [...this.form.base, ...resolvedFullPath] : resolvedFullPath; - - const table = createTable.call(this, externalPath, { - onUpdate: updateFunction, - onThrow: this.#onThrow, - }); // Ensure change propagates - - if (table) { - this.setAttribute("data-table", ""); - return table; - } - } - - const addButton = new Button({ - size: "small", - }); - - addButton.innerText = `Add ${canAddProperties ? "Property" : "Item"}`; - - const buttonDiv = document.createElement("div"); - Object.assign(buttonDiv.style, { width: "fit-content" }); - buttonDiv.append(addButton); - - const disableButton = ({ message, submessage }) => { - addButton.setAttribute("disabled", true); - tippy(buttonDiv, { - content: `
${message}
${submessage}
`, - allowHTML: true, - }); - }; - - const list = (this.#list = new List({ - items: this.#mapToList(), - - // Add edit button when new items are added - // NOTE: Duplicates some code in #mapToList - transform: (item) => { - if (canAddProperties) { - const { key, value } = item; - item.controls = [ - new Button({ - label: "Edit", - size: "small", - onClick: () => { - this.#createModal({ - key, - schema, - results: value, - list, - }); - }, - }), - ]; - } - }, - onChange: async ({ object, items }, { object: oldObject }) => { - if (this.pattern) { - const oldKeys = Object.keys(oldObject); - const newKeys = Object.keys(object); - const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); - const updatedKeys = newKeys.filter((k) => oldObject[k] !== object[k]); - removedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], undefined)); - updatedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], object[k])); - } else { - this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); - } - - if (validateOnChange) await this.#triggerValidation(name, path); - }, - })); - - if (allowAdditionalProperties) - disableButton({ - message: "Additional properties cannot be added at this time.", - submessage: "They don't have a predictable structure.", - }); - - addButton.onClick = () => - this.#createModal({ label: name, list, schema: allowPatternProperties ? schema : itemSchema }); - - return html` -
validateOnChange && this.#triggerValidation(name, path)}> - ${list} ${buttonDiv} -
- `; - } - - // Basic enumeration of properties on a select element - if (schema.enum && schema.enum.length) { - - // Use generic selector - if (schema.strict && schema.search !== true) { - return html` - - `; - } - - const options = schema.enum.map((v) => { - return { - key: v, - value: v, - category: schema.enumCategories?.[v], - label: schema.enumLabels?.[v] ?? v, - keywords: schema.enumKeywords?.[v], - description: schema.enumDescriptions?.[v], - link: schema.enumLinks?.[v], - }; - }); - - const search = new Search({ - options, - strict: schema.strict, - value: { - value: this.value, - key: this.value, - category: schema.enumCategories?.[this.value], - label: schema.enumLabels?.[this.value], - keywords: schema.enumKeywords?.[this.value], - }, - showAllWhenEmpty: false, - listMode: "input", - onSelect: async ({ value, key }) => { - const result = value ?? key; - this.#updateData(fullPath, result); - if (validateOnChange) await this.#triggerValidation(name, path); - }, - }); - - search.classList.add("schema-input"); - search.onchange = () => validateOnChange && this.#triggerValidation(name, path); // Ensure validation on forced change - - search.addEventListener("keydown", this.#moveToNextInput); - this.style.width = "100%"; - return search; - } else if (schema.type === "boolean") { - const optional = new OptionalSection({ - value: this.value ?? false, - color: "rgb(32,32,32)", - size: "small", - onSelect: (value) => this.#updateData(fullPath, value), - onChange: () => validateOnChange && this.#triggerValidation(name, path), - }); - - optional.classList.add("schema-input"); - return optional; - } else if (schema.type === "string" || schema.type === "number" || schema.type === "integer") { - const isInteger = schema.type === "integer"; - if (isInteger) schema.type = "number"; - const isNumber = schema.type === "number"; - - const isRequiredNumber = isNumber && this.required; - - const fileSystemFormat = isFilesystemSelector(name, schema.format); - if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); - // Handle long string formats - else if (schema.format === "long" || isArray) - return html``; - // Handle other string formats - else { - const isDateTime = schema.format === "date-time"; - - const type = isDateTime - ? "datetime-local" - : schema.format ?? (schema.type === "string" ? "text" : schema.type); - - const value = isDateTime ? resolveDateTime(this.value) : this.value; - - const { minimum, maximum, exclusiveMax, exclusiveMin } = schema; - const min = exclusiveMin ?? minimum; - const max = exclusiveMax ?? maximum; - - return html` - { - let value = ev.target.value; - let newValue = value; - - // const isBlank = value === ''; - - if (isInteger) value = newValue = parseInt(value); - else if (isNumber) value = newValue = parseFloat(value); - - if (isNumber) { - if ("min" in schema && newValue < schema.min) newValue = schema.min; - else if ("max" in schema && newValue > schema.max) newValue = schema.max; - - if (isNaN(newValue)) newValue = undefined; - } - - if (schema.transform) newValue = schema.transform(newValue, this.value, schema); - - // // Do not check pattern if value is empty - // if (schema.pattern && !isBlank) { - // const regex = new RegExp(schema.pattern) - // if (!regex.test(isNaN(newValue) ? value : newValue)) newValue = this.value // revert to last value - // } - - if (isNumber && newValue !== value) { - ev.target.value = newValue; - value = newValue; - } - - if (isRequiredNumber) { - const nanHandler = ev.target.parentNode.querySelector(".nan-handler"); - if (!(newValue && Number.isNaN(newValue))) nanHandler.checked = false; - } - - this.#updateData(fullPath, value); - }} - @change=${(ev) => validateOnChange && this.#triggerValidation(name, path)} - @keydown=${this.#moveToNextInput} - /> - ${schema.unit ?? ""} - ${isRequiredNumber - ? html`
{ - const siblingInput = ev.target.parentNode.previousElementSibling; - if (ev.target.checked) { - this.#updateData(fullPath, null); - siblingInput.setAttribute("disabled", true); - } else { - siblingInput.removeAttribute("disabled"); - const ev = new Event("input"); - siblingInput.dispatchEvent(ev); - } - this.#triggerValidation(name, path); - }} - >I Don't Know
` - : ""} - `; - } - } - - return this.onUncaughtSchema(schema); - } -} - -customElements.get("jsonschema-input") || customElements.define("jsonschema-input", JSONSchemaInput); +import { LitElement, css, html } from "lit"; +import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { FilesystemSelector } from "./FileSystemSelector"; + +import { BasicTable } from "./BasicTable"; +import { header, tempPropertyKey, tempPropertyValueKey } from "./forms/utils"; + +import { Button } from "./Button"; +import { List } from "./List"; +import { Modal } from "./Modal"; + +import { capitalize } from "./forms/utils"; +import { JSONSchemaForm, getIgnore } from "./JSONSchemaForm"; +import { Search } from "./Search"; +import tippy from "tippy.js"; +import { merge } from "./pages/utils"; +import { OptionalSection } from "./OptionalSection"; +import { InspectorListItem } from "./preview/inspector/InspectorList"; + +const isDevelopment = !!import.meta.env; + +const dateTimeRegex = /(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/; + +function resolveDateTime(value) { + if (typeof value === "string") { + const match = value.match(dateTimeRegex); + if (match) return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}`; + return value; + } + + return value; +} + +export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { + const name = fullPath.slice(-1)[0]; + const path = fullPath.slice(0, -1); + const relativePath = this.form?.base ? fullPath.slice(this.form.base.length) : fullPath; + + const schema = this.schema; + const validateOnChange = this.validateOnChange; + + const ignore = this.form?.ignore ? getIgnore(this.form?.ignore, path) : {}; + + const commonValidationFunction = async (tableBasePath, path, parent, newValue, itemPropSchema) => { + const warnings = []; + const errors = []; + + const name = path.slice(-1)[0]; + const completePath = [...tableBasePath, ...path.slice(0, -1)]; + + const result = await (validateOnChange + ? this.onValidate + ? this.onValidate() + : this.form?.triggerValidation + ? this.form.triggerValidation( + name, + completePath, + false, + this, + itemPropSchema, + { ...parent, [name]: newValue }, + { + onError: (error) => { + errors.push(error); // Skip counting errors + }, + onWarning: (warning) => { + warnings.push(warning); // Skip counting warnings + }, + } + ) // NOTE: No pattern properties support + : "" + : true); + + const returnedValue = errors.length ? errors : warnings.length ? warnings : result; + + return returnedValue; + }; + + const commonTableMetadata = { + onStatusChange: () => this.form?.checkStatus && this.form.checkStatus(), // Check status on all elements + validateEmptyCells: this.validateEmptyValue, + deferLoading: this.form?.deferLoading, + onLoaded: () => { + if (this.form) { + if (this.form.nLoaded) this.form.nLoaded++; + if (this.form.checkAllLoaded) this.form.checkAllLoaded(); + } + }, + onThrow: (...args) => onThrow(...args), + }; + + const addPropertyKeyToSchema = (schema) => { + const schemaCopy = structuredClone(schema); + + const schemaItemsRef = schemaCopy["items"]; + + if (!schemaItemsRef.properties) schemaItemsRef.properties = {}; + if (!schemaItemsRef.required) schemaItemsRef.required = []; + + schemaItemsRef.properties[tempPropertyKey] = { title: "Property Key", type: "string", pattern: name }; + if (!schemaItemsRef.order) schemaItemsRef.order = []; + schemaItemsRef.order.unshift(tempPropertyKey); + + schemaItemsRef.required.push(tempPropertyKey); + + return schemaCopy; + }; + + const createNestedTable = (id, value, { name: propName = id, nestedSchema = schema } = {}) => { + const schemaCopy = addPropertyKeyToSchema(nestedSchema); + + const resultPath = [...path]; + + const schemaPath = [...fullPath]; + + // THIS IS AN ISSUE + const rowData = Object.entries(value).map(([key, value]) => { + return !schemaCopy["items"] + ? { [tempPropertyKey]: key, [tempPropertyValueKey]: value } + : { [tempPropertyKey]: key, ...value }; + }); + + if (propName) { + resultPath.push(propName); + schemaPath.push(propName); + } + + const allRemovedKeys = new Set(); + + const keyAlreadyExists = (key) => Object.keys(value).includes(key); + + const previousValidValues = {}; + + function resolvePath(path, target) { + return path + .map((key, i) => { + const ogKey = key; + const nextKey = path[i + 1]; + if (key === tempPropertyKey) key = target[tempPropertyKey]; + if (nextKey === tempPropertyKey) key = []; + + target = target[ogKey] ?? {}; + + if (nextKey === tempPropertyValueKey) return target[tempPropertyKey]; // Grab next property key + if (key === tempPropertyValueKey) return []; + + return key; + }) + .flat(); + } + + function setValueOnAccumulator(row, acc) { + const key = row[tempPropertyKey]; + + if (!key) return acc; + + if (tempPropertyValueKey in row) { + const propValue = row[tempPropertyValueKey]; + if (Array.isArray(propValue)) + acc[key] = propValue.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); + else acc[key] = propValue; + } else { + const copy = { ...row }; + delete copy[tempPropertyKey]; + acc[key] = copy; + } + + return acc; + } + + const nestedIgnore = this.form?.ignore ? getIgnore(this.form?.ignore, schemaPath) : {}; + + merge(overrides.ignore, nestedIgnore); + + merge(overrides.schema, schemaCopy, { arrays: "append" }); + + const tableMetadata = { + keyColumn: tempPropertyKey, + schema: schemaCopy, + data: rowData, + ignore: nestedIgnore, // According to schema + + onUpdate: function (path, newValue) { + const oldKeys = Object.keys(value); + + if (path.slice(-1)[0] === tempPropertyKey && keyAlreadyExists(newValue)) return; // Do not overwrite existing keys + + const result = this.data.reduce((acc, row) => setValueOnAccumulator(row, acc), {}); + + const newKeys = Object.keys(result); + const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); + removedKeys.forEach((key) => allRemovedKeys.add(key)); + newKeys.forEach((key) => allRemovedKeys.delete(key)); + allRemovedKeys.forEach((key) => (result[key] = undefined)); + + // const resolvedPath = resolvePath(path, this.data) + return onUpdate.call(this, [], result); // Update all table data + }, + + validateOnChange: function (path, parent, newValue) { + const rowIdx = path[0]; + const currentKey = this.data[rowIdx]?.[tempPropertyKey]; + + const updatedPath = resolvePath(path, this.data); + + const resolvedKey = previousValidValues[rowIdx] ?? currentKey; + + // Do not overwrite existing keys + if (path.slice(-1)[0] === tempPropertyKey && resolvedKey !== newValue) { + if (keyAlreadyExists(newValue)) { + if (!previousValidValues[rowIdx]) previousValidValues[rowIdx] = resolvedKey; + + return [ + { + message: `Key already exists.
This value is still ${resolvedKey}.`, + type: "error", + }, + ]; + } else delete previousValidValues[rowIdx]; + } + + const toIterate = updatedPath.filter((value) => typeof value === "string"); + + const itemPropsSchema = toIterate.reduce( + (acc, key) => acc?.properties?.[key] ?? acc?.items?.properties?.[key], + schemaCopy + ); + + return commonValidationFunction([], updatedPath, parent, newValue, itemPropsSchema, 1); + }, + ...commonTableMetadata, + }; + + const table = this.renderTable(id, tableMetadata, fullPath); + + return table; // Try rendering as a nested table with a fake property key (otherwise use nested forms) + }; + + const schemaCopy = structuredClone(schema); + + // Possibly multiple tables + if (isEditableObject(schema, this.value)) { + // One table with nested tables for each property + const data = getEditableItems(this.value, this.pattern, { name, schema: schemaCopy }).reduce( + (acc, { key, value }) => { + acc[key] = value; + return acc; + }, + {} + ); + + const table = createNestedTable(name, data, { schema }); + if (table) return table; + } + + const nestedIgnore = getIgnore(ignore, fullPath); + Object.assign(nestedIgnore, overrides.ignore ?? {}); + + merge(overrides.ignore, nestedIgnore); + + merge(overrides.schema, schemaCopy, { arrays: "append" }); + + // Normal table parsing + const tableMetadata = { + schema: schemaCopy, + data: this.value, + + ignore: nestedIgnore, // According to schema + + onUpdate: function () { + return onUpdate.call(this, relativePath, this.data); // Update all table data + }, + + validateOnChange: (...args) => commonValidationFunction(relativePath, ...args), + + ...commonTableMetadata, + }; + + const table = (this.table = this.renderTable(name, tableMetadata, path)); // Try creating table. Otherwise use nested form + + if (table) { + const tableEl = table === true ? new BasicTable(tableMetadata) : table; + const tables = this.form?.tables; + if (tables) tables[name] = tableEl; + return tableEl; + } +} + +// Schema or value indicates editable object +export const isEditableObject = (schema, value) => + schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value)); + +export const isAdditionalProperties = (pattern) => pattern === "additional"; +export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern); + +export const getEditableItems = (value = {}, pattern, { name, schema } = {}) => { + let items = Object.entries(value); + + const allowAdditionalProperties = isAdditionalProperties(pattern); + + if (isPatternProperties(pattern)) { + const regex = new RegExp(name); + items = items.filter(([key]) => regex.test(key)); + } else if (allowAdditionalProperties) { + const props = Object.keys(schema.properties ?? {}); + items = items.filter(([key]) => !props.includes(key)); + + const patternProps = Object.keys(schema.patternProperties ?? {}); + patternProps.forEach((key) => { + const regex = new RegExp(key); + items = items.filter(([k]) => !regex.test(k)); + }); + } else if (schema.properties) items = items.filter(([key]) => key in schema.properties); + + items = items.filter(([key]) => !key.includes("__")); // Remove secret properties + + return items.map(([key, value]) => { + return { key, value }; + }); +}; + +const isFilesystemSelector = (name = "", format) => { + if (Array.isArray(format)) return format.map((f) => isFilesystemSelector(name, f)).every(Boolean) ? format : null; + + const matched = name.match(/(.+_)?(.+)_paths?/); + if (!format && matched) format = matched[2] === "folder" ? "directory" : matched[2]; + return ["file", "directory"].includes(format) ? format : null; // Handle file and directory formats +}; + +function getFirstFocusableElement(element) { + const root = element.shadowRoot || element; + const focusableElements = getKeyboardFocusableElements(root); + if (focusableElements.length === 0) { + for (let child of root.children) { + const focusableElement = getFirstFocusableElement(child); + if (focusableElement) return focusableElement; + } + } + return focusableElements[0]; +} + +function getKeyboardFocusableElements(element = document) { + const root = element.shadowRoot || element; + return [ + ...root.querySelectorAll('a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])'), + ].filter( + (focusableElement) => + !focusableElement.hasAttribute("disabled") && !focusableElement.getAttribute("aria-hidden") + ); +} + +export class JSONSchemaInput extends LitElement { + static get styles() { + return css` + * { + box-sizing: border-box; + } + + :host(.invalid) .guided--input { + background: rgb(255, 229, 228) !important; + } + + jsonschema-input { + width: 100%; + } + + main { + display: flex; + align-items: center; + } + + #controls { + margin-left: 10px; + flex-grow: 1; + } + + .guided--input { + width: 100%; + border-radius: 4px; + padding: 10px 12px; + font-size: 100%; + font-weight: normal; + border: 1px solid var(--color-border); + transition: border-color 150ms ease-in-out 0s; + outline: none; + color: rgb(33, 49, 60); + background-color: rgb(255, 255, 255); + } + + .guided--input:disabled { + opacity: 0.5; + pointer-events: none; + } + + .guided--input::placeholder { + opacity: 0.5; + } + + .guided--text-area { + height: 5em; + resize: none; + font-family: unset; + } + .guided--text-area-tall { + height: 15em; + } + .guided--input:hover { + box-shadow: rgb(231 238 236) 0px 0px 0px 2px; + } + .guided--input:focus { + outline: 0; + box-shadow: var(--color-light-green) 0px 0px 0px 1px; + } + + input[type="number"].hideStep::-webkit-outer-spin-button, + input[type="number"].hideStep::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + /* Firefox */ + input[type="number"].hideStep { + -moz-appearance: textfield; + } + + .guided--text-input-instructions { + font-size: 13px; + width: 100%; + padding-top: 4px; + color: dimgray !important; + margin: 0 0; + line-height: 1.4285em; + } + + .nan-handler { + display: flex; + align-items: center; + margin-left: 5px; + white-space: nowrap; + } + + .nan-handler span { + margin-left: 5px; + font-size: 12px; + } + + .schema-input.list { + width: 100%; + } + + .guided--form-label { + display: block; + width: 100%; + margin: 0; + margin-bottom: 10px; + color: black; + font-weight: 600; + font-size: 1.2em !important; + } + + :host([data-table]) .guided--form-label { + margin-bottom: 0px; + } + + .guided--form-label.centered { + text-align: center; + } + + .guided--form-label.header { + font-size: 1.5em !important; + } + + .required label:after { + content: " *"; + color: #ff0033; + } + + :host(:not([validateemptyvalue])) .required label:after { + color: gray; + } + + .required.conditional label:after { + color: transparent; + } + + hr { + display: block; + height: 1px; + border: 0; + border-top: 1px solid #ccc; + padding: 0; + margin-bottom: 1em; + } + + select { + background: url("data:image/svg+xml,") + no-repeat; + background-position: calc(100% - 0.75rem) center !important; + -moz-appearance: none !important; + -webkit-appearance: none !important; + appearance: none !important; + padding-right: 2rem !important; + } + `; + } + + static get properties() { + return { + schema: { type: Object, reflect: false }, + validateEmptyValue: { type: Boolean, reflect: true }, + required: { type: Boolean, reflect: true }, + }; + } + + // Enforce dynamic required properties + attributeChangedCallback(key, _, latest) { + super.attributeChangedCallback(...arguments); + + const formSchema = this.form?.schema; + if (!formSchema) return; + + if (key === "required") { + const name = this.path.slice(-1)[0]; + + if (latest !== null && !this.conditional) { + const requirements = formSchema.required ?? (formSchema.required = []); + if (!requirements.includes(name)) requirements.push(name); + } + + // Remove requirement from form schema (and force if conditional requirement) + else { + const requirements = formSchema.required; + if (requirements && requirements.includes(name)) { + const idx = requirements.indexOf(name); + if (idx > -1) requirements.splice(idx, 1); + } + } + } + } + + // schema, + // parent, + // path, + // form, + // pattern + // showLabel + // description + controls = []; + // required; + validateOnChange = true; + + constructor(props = {}) { + super(); + Object.assign(this, props); + if (props.validateEmptyValue === false) this.validateEmptyValue = true; // False is treated as required but not triggered if empty + } + + // Print the default value of the schema if not caught + onUncaughtSchema = (schema) => { + // In development, show uncaught schemas + if (!isDevelopment) { + if (this.form) { + const inputContainer = this.form.shadowRoot.querySelector(`#${this.path.slice(-1)[0]}`); + inputContainer.style.display = "none"; + } + } + + if (schema.default) return `
${JSON.stringify(schema.default, null, 2)}
`; + + const error = new InspectorListItem({ + message: + "

Internal GUIDE Error

Cannot render this property because of a misformatted schema.", + }); + error.style.width = "100%"; + + return error; + }; + + // onUpdate = () => {} + // onValidate = () => {} + + updateData(value, forceValidate = false) { + if (!forceValidate) { + // Update the actual input element + const inputElement = this.getElement(); + if (!inputElement) return false; + + const hasList = inputElement.querySelector("nwb-list"); + + if (inputElement.type === "checkbox") inputElement.checked = value; + else if (hasList) + hasList.items = this.#mapToList({ value, hasList }); // NOTE: Make sure this is correct + else if (inputElement instanceof Search) inputElement.shadowRoot.querySelector("input").value = value; + else inputElement.value = value; + } + + const { path: fullPath } = this; + const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; + const name = path.splice(-1)[0]; + + this.#updateData(fullPath, value); + this.#triggerValidation(name, path); // NOTE: Is asynchronous + + return true; + } + + getElement = () => this.shadowRoot.querySelector(".schema-input"); + + #activateTimeoutValidation = (name, path, hooks) => { + this.#clearTimeoutValidation(); + this.#validationTimeout = setTimeout(() => { + this.onValidate + ? this.onValidate() + : this.form?.triggerValidation + ? this.form.triggerValidation(name, path, undefined, this, undefined, undefined, hooks) + : ""; + }, 1000); + }; + + #clearTimeoutValidation = () => { + if (this.#validationTimeout) clearTimeout(this.#validationTimeout); + }; + + #validationTimeout = null; + #updateData = (fullPath, value, forceUpdate, hooks = {}) => { + this.onUpdate + ? this.onUpdate(value) + : this.form?.updateData + ? this.form.updateData(fullPath, value, forceUpdate) + : ""; + + const path = [...fullPath]; + const name = path.splice(-1)[0]; + + this.value = value; // Update the latest value + + if (hooks.willTimeout !== false) this.#activateTimeoutValidation(name, path, hooks); + }; + + #triggerValidation = async (name, path) => { + this.#clearTimeoutValidation(); + return this.onValidate + ? this.onValidate() + : this.form?.triggerValidation + ? this.form.triggerValidation(name, path, undefined, this) + : ""; + }; + + updated() { + const inputElement = this.getElement(); + if (inputElement) inputElement.dispatchEvent(new Event("change")); + } + + render() { + const { schema } = this; + + const input = this.#render(); + + if (input === null) return null; // Hide rendering + + const description = this.description ?? schema.description; + + const descriptionHTML = description + ? html`

+ ${unsafeHTML(capitalize(description))}${[".", "?", "!"].includes(description.slice(-1)[0]) ? "" : "."} +

` + : ""; + + return html` +
+ ${this.showLabel + ? html`` + : ""} +
${input}${this.controls ? html`
${this.controls}
` : ""}
+ ${descriptionHTML} +
+ `; + } + + #onThrow = (...args) => (this.onThrow ? this.onThrow(...args) : this.form?.onThrow(...args)); + + #list; + #mapToList({ value = this.value, schema = this.schema, list } = {}) { + const { path: fullPath } = this; + const path = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; + const name = path.splice(-1)[0]; + + const canAddProperties = isEditableObject(this.schema, this.value); + + if (canAddProperties) { + const editable = getEditableItems(this.value, this.pattern, { name, schema }); + + return editable.map(({ key, value }) => { + return { + key, + value, + controls: [ + new Button({ + label: "Edit", + size: "small", + onClick: () => + this.#createModal({ + key, + schema: isAdditionalProperties(this.pattern) ? undefined : schema, + results: value, + list: list ?? this.#list, + }), + }), + ], + }; + }); + } else { + const resolved = value ?? []; + return resolved + ? resolved.map((value) => { + return { value }; + }) + : []; + } + } + + #modal; + + #createModal({ key, schema = {}, results, list, label } = {}) { + const schemaCopy = structuredClone(schema); + + const createNewObject = !results && (schemaCopy.type === "object" || schemaCopy.properties); + + // const schemaProperties = Object.keys(schema.properties ?? {}); + // const additionalProperties = Object.keys(results).filter((key) => !schemaProperties.includes(key)); + // // const additionalElement = html`Cannot edit additional properties (${additionalProperties}) at this time` + + const allowPatternProperties = isPatternProperties(this.pattern); + const allowAdditionalProperties = isAdditionalProperties(this.pattern); + const createNewPatternProperty = allowPatternProperties && createNewObject; + + // Add a property name entry to the schema + if (createNewPatternProperty) { + schemaCopy.properties = { + __: { title: "Property Name", type: "string", pattern: this.pattern }, + ...schemaCopy.properties, + }; + schemaCopy.required = [...(schemaCopy.required ?? []), "__"]; + } + + if (this.#modal) this.#modal.remove(); + + const submitButton = new Button({ + label: "Submit", + primary: true, + }); + + const isObject = schemaCopy.type === "object" || schemaCopy.properties; // NOTE: For formatted strings, this is not an object + + // NOTE: Will be replaced by single instances + let updateTarget = results ?? (isObject ? {} : undefined); + + submitButton.onClick = async () => { + await nestedModalElement.validate(); + + let value = updateTarget; + + if (schemaCopy?.format && schemaCopy.properties) { + let newValue = schemaCopy?.format; + for (let key in schemaCopy.properties) newValue = newValue.replace(`{${key}}`, value[key] ?? "").trim(); + value = newValue; + } + + // Skip if not unique + if (schemaCopy.uniqueItems && list.items.find((item) => item.value === value)) + return this.#modal.toggle(false); + + // Add to the list + if (createNewPatternProperty) { + const key = value.__; + delete value.__; + list.add({ key, value }); + } else list.add({ key, value }); + + this.#modal.toggle(false); + }; + + this.#modal = new Modal({ + header: label ? `${header(label)} Editor` : key ? header(key) : `Property Editor`, + footer: submitButton, + showCloseButton: createNewObject, + }); + + const div = document.createElement("div"); + div.style.padding = "25px"; + + const inputTitle = header(schemaCopy.title ?? label ?? "Value"); + + const nestedModalElement = isObject + ? new JSONSchemaForm({ + schema: schemaCopy, + results: updateTarget, + validateEmptyValues: false, + onUpdate: (internalPath, value) => { + if (!createNewObject) { + const path = [key, ...internalPath]; + this.#updateData(path, value, true); // Live updates + } + }, + renderTable: this.renderTable, + onThrow: this.#onThrow, + }) + : new JSONSchemaForm({ + schema: { + properties: { + [tempPropertyKey]: { + ...schemaCopy, + title: inputTitle, + }, + }, + required: [tempPropertyKey], + }, + validateEmptyValues: false, + results: updateTarget, + onUpdate: (_, value) => { + if (createNewObject) updateTarget[key] = value; + else updateTarget = value; + }, + // renderTable: this.renderTable, + // onThrow: this.#onThrow, + }); + + div.append(nestedModalElement); + + this.#modal.append(div); + + document.body.append(this.#modal); + + setTimeout(() => this.#modal.toggle(true)); + + return this.#modal; + } + + #getType = (value = this.value) => (Array.isArray(value) ? "array" : typeof value); + + #handleNextInput = (idx) => { + const next = Object.values(this.form.inputs)[idx]; + if (next) { + const firstFocusableElement = getFirstFocusableElement(next); + if (firstFocusableElement) { + if (firstFocusableElement.tagName === "BUTTON") return this.#handleNextInput(idx + 1); + firstFocusableElement.focus(); + } + } + }; + + #moveToNextInput = (ev) => { + if (ev.key === "Enter") { + ev.preventDefault(); + if (this.form?.inputs) { + const idx = Object.values(this.form.inputs).findIndex((input) => input === this); + this.#handleNextInput(idx + 1); + } + + ev.target.blur(); + } + }; + + #render() { + const { validateOnChange, schema, path: fullPath } = this; + + this.removeAttribute("data-table"); + + // Do your best to fill in missing schema values + if (!("type" in schema)) schema.type = this.#getType(); + + const resolvedFullPath = typeof fullPath === "string" ? fullPath.split("-") : [...fullPath]; + const path = [...resolvedFullPath]; + const name = path.splice(-1)[0]; + + const isArray = schema.type === "array"; // Handle string (and related) formats / types + + const itemSchema = this.form?.getSchema ? this.form.getSchema("items", schema) : schema["items"]; + const isTable = itemSchema?.type === "object" && this.renderTable; + + const canAddProperties = isEditableObject(this.schema, this.value); + + if (this.renderCustomHTML) { + const custom = this.renderCustomHTML(name, schema, path, { + onUpdate: this.#updateData, + onThrow: this.#onThrow, + }); + + const renderEmpty = custom === null; + if (custom) return custom; + else if (renderEmpty) { + this.remove(); // Remove from DOM so that parent can be empty + return; + } + } + + // Handle file and directory formats + const createFilesystemSelector = (format) => { + const filesystemSelectorElement = new FilesystemSelector({ + type: format, + value: this.value, + accept: schema.accept, + onSelect: (paths = []) => { + const value = paths.length ? paths : undefined; + this.#updateData(fullPath, value); + }, + onChange: (filePath) => validateOnChange && this.#triggerValidation(name, path), + onThrow: (...args) => this.#onThrow(...args), + dialogOptions: this.form?.dialogOptions, + dialogType: this.form?.dialogType, + multiple: isArray, + }); + filesystemSelectorElement.classList.add("schema-input"); + return filesystemSelectorElement; + }; + + // Transform to single item if maxItems is 1 + if (isArray && schema.maxItems === 1 && !isTable) { + return new JSONSchemaInput({ + value: this.value?.[0], + schema: { + ...schema.items, + strict: schema.strict, + }, + path: fullPath, + validateEmptyValue: this.validateEmptyValue, + required: this.required, + validateOnChange: () => (validateOnChange ? this.#triggerValidation(name, path) : ""), + form: this.form, + onUpdate: (value) => this.#updateData(fullPath, [value]), + }); + } + + if (isArray || canAddProperties) { + // if ('value' in this && !Array.isArray(this.value)) this.value = [ this.value ] + + const allowPatternProperties = isPatternProperties(this.pattern); + const allowAdditionalProperties = isAdditionalProperties(this.pattern); + + // Provide default item types + if (isArray) { + const hasItemsRef = "items" in schema && "$ref" in schema.items; + if (!("items" in schema)) schema.items = {}; + if (!("type" in schema.items) && !hasItemsRef) { + // Guess the type of the first item + if (this.value) { + const itemToCheck = this.value[0]; + schema.items.type = itemToCheck ? this.#getType(itemToCheck) : "string"; + } + + // If no value, handle uncaught schema + else return this.onUncaughtSchema(schema); + } + } + + const fileSystemFormat = isFilesystemSelector(name, itemSchema?.format); + if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); + // Create tables if possible + else if (itemSchema?.type === "string" && !itemSchema.properties) { + const list = new List({ + items: this.value, + emptyMessage: "No items", + onChange: ({ items }) => { + this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); + if (validateOnChange) this.#triggerValidation(name, path); + }, + }); + + if (itemSchema.enum) { + const search = new Search({ + options: itemSchema.enum.map((v) => { + return { + key: v, + value: v, + label: itemSchema.enumLabels?.[v] ?? v, + keywords: itemSchema.enumKeywords?.[v], + description: itemSchema.enumDescriptions?.[v], + link: itemSchema.enumLinks?.[v], + }; + }), + value: this.value, + listMode: schema.strict === false ? "click" : "append", + showAllWhenEmpty: false, + onSelect: async ({ label, value }) => { + if (!value) return; + if (schema.uniqueItems && this.value && this.value.includes(value)) return; + list.add({ content: label, value }); + }, + }); + + search.style.height = "auto"; + return html`
${search}${list}
`; + } else { + const input = document.createElement("input"); + input.classList.add("guided--input"); + input.placeholder = "Provide an item for the list"; + + const submitButton = new Button({ + label: "Submit", + primary: true, + size: "small", + onClick: () => { + const value = input.value; + if (!value) return; + if (schema.uniqueItems && this.value && this.value.includes(value)) return; + list.add({ value }); + input.value = ""; + }, + }); + + input.addEventListener("keydown", (ev) => { + if (ev.key === "Enter") submitButton.onClick(); + }); + + return html`
validateOnChange && this.#triggerValidation(name, path)} + > +
${input}${submitButton}
+ ${list} +
`; + } + } else if (isTable) { + const instanceThis = this; + + function updateFunction(path, value = this.data) { + return instanceThis.#updateData(path, value, true, { + willTimeout: false, // Since there is a special validation function, do not trigger a timeout validation call + onError: (e) => e, + onWarning: (e) => e, + }); + } + + const externalPath = this.form?.base ? [...this.form.base, ...resolvedFullPath] : resolvedFullPath; + + const table = createTable.call(this, externalPath, { + onUpdate: updateFunction, + onThrow: this.#onThrow, + }); // Ensure change propagates + + if (table) { + this.setAttribute("data-table", ""); + return table; + } + } + + const addButton = new Button({ + size: "small", + }); + + addButton.innerText = `Add ${canAddProperties ? "Property" : "Item"}`; + + const buttonDiv = document.createElement("div"); + Object.assign(buttonDiv.style, { width: "fit-content" }); + buttonDiv.append(addButton); + + const disableButton = ({ message, submessage }) => { + addButton.setAttribute("disabled", true); + tippy(buttonDiv, { + content: `
${message}
${submessage}
`, + allowHTML: true, + }); + }; + + const list = (this.#list = new List({ + items: this.#mapToList(), + + // Add edit button when new items are added + // NOTE: Duplicates some code in #mapToList + transform: (item) => { + if (canAddProperties) { + const { key, value } = item; + item.controls = [ + new Button({ + label: "Edit", + size: "small", + onClick: () => { + this.#createModal({ + key, + schema, + results: value, + list, + }); + }, + }), + ]; + } + }, + onChange: async ({ object, items }, { object: oldObject }) => { + if (this.pattern) { + const oldKeys = Object.keys(oldObject); + const newKeys = Object.keys(object); + const removedKeys = oldKeys.filter((k) => !newKeys.includes(k)); + const updatedKeys = newKeys.filter((k) => oldObject[k] !== object[k]); + removedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], undefined)); + updatedKeys.forEach((k) => this.#updateData([...fullPath.slice(1), k], object[k])); + } else { + this.#updateData(fullPath, items.length ? items.map((o) => o.value) : undefined); + } + + if (validateOnChange) await this.#triggerValidation(name, path); + }, + })); + + if (allowAdditionalProperties) + disableButton({ + message: "Additional properties cannot be added at this time.", + submessage: "They don't have a predictable structure.", + }); + + addButton.onClick = () => + this.#createModal({ label: name, list, schema: allowPatternProperties ? schema : itemSchema }); + + return html` +
validateOnChange && this.#triggerValidation(name, path)}> + ${list} ${buttonDiv} +
+ `; + } + + // Basic enumeration of properties on a select element + if (schema.enum && schema.enum.length) { + // Use generic selector + if (schema.strict && schema.search !== true) { + return html` + + `; + } + + const options = schema.enum.map((v) => { + return { + key: v, + value: v, + category: schema.enumCategories?.[v], + label: schema.enumLabels?.[v] ?? v, + keywords: schema.enumKeywords?.[v], + description: schema.enumDescriptions?.[v], + link: schema.enumLinks?.[v], + }; + }); + + const search = new Search({ + options, + strict: schema.strict, + value: { + value: this.value, + key: this.value, + category: schema.enumCategories?.[this.value], + label: schema.enumLabels?.[this.value], + keywords: schema.enumKeywords?.[this.value], + }, + showAllWhenEmpty: false, + listMode: "input", + onSelect: async ({ value, key }) => { + const result = value ?? key; + this.#updateData(fullPath, result); + if (validateOnChange) await this.#triggerValidation(name, path); + }, + }); + + search.classList.add("schema-input"); + search.onchange = () => validateOnChange && this.#triggerValidation(name, path); // Ensure validation on forced change + + search.addEventListener("keydown", this.#moveToNextInput); + this.style.width = "100%"; + return search; + } else if (schema.type === "boolean") { + const optional = new OptionalSection({ + value: this.value ?? false, + color: "rgb(32,32,32)", + size: "small", + onSelect: (value) => this.#updateData(fullPath, value), + onChange: () => validateOnChange && this.#triggerValidation(name, path), + }); + + optional.classList.add("schema-input"); + return optional; + } else if (schema.type === "string" || schema.type === "number" || schema.type === "integer") { + const isInteger = schema.type === "integer"; + if (isInteger) schema.type = "number"; + const isNumber = schema.type === "number"; + + const isRequiredNumber = isNumber && this.required; + + const fileSystemFormat = isFilesystemSelector(name, schema.format); + if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); + // Handle long string formats + else if (schema.format === "long" || isArray) + return html``; + // Handle other string formats + else { + const isDateTime = schema.format === "date-time"; + + const type = isDateTime + ? "datetime-local" + : schema.format ?? (schema.type === "string" ? "text" : schema.type); + + const value = isDateTime ? resolveDateTime(this.value) : this.value; + + const { minimum, maximum, exclusiveMax, exclusiveMin } = schema; + const min = exclusiveMin ?? minimum; + const max = exclusiveMax ?? maximum; + + return html` + { + let value = ev.target.value; + let newValue = value; + + // const isBlank = value === ''; + + if (isInteger) value = newValue = parseInt(value); + else if (isNumber) value = newValue = parseFloat(value); + + if (isNumber) { + if ("min" in schema && newValue < schema.min) newValue = schema.min; + else if ("max" in schema && newValue > schema.max) newValue = schema.max; + + if (isNaN(newValue)) newValue = undefined; + } + + if (schema.transform) newValue = schema.transform(newValue, this.value, schema); + + // // Do not check pattern if value is empty + // if (schema.pattern && !isBlank) { + // const regex = new RegExp(schema.pattern) + // if (!regex.test(isNaN(newValue) ? value : newValue)) newValue = this.value // revert to last value + // } + + if (isNumber && newValue !== value) { + ev.target.value = newValue; + value = newValue; + } + + if (isRequiredNumber) { + const nanHandler = ev.target.parentNode.querySelector(".nan-handler"); + if (!(newValue && Number.isNaN(newValue))) nanHandler.checked = false; + } + + this.#updateData(fullPath, value); + }} + @change=${(ev) => validateOnChange && this.#triggerValidation(name, path)} + @keydown=${this.#moveToNextInput} + /> + ${schema.unit ?? ""} + ${isRequiredNumber + ? html`
{ + const siblingInput = ev.target.parentNode.previousElementSibling; + if (ev.target.checked) { + this.#updateData(fullPath, null); + siblingInput.setAttribute("disabled", true); + } else { + siblingInput.removeAttribute("disabled"); + const ev = new Event("input"); + siblingInput.dispatchEvent(ev); + } + this.#triggerValidation(name, path); + }} + >I Don't Know
` + : ""} + `; + } + } + + return this.onUncaughtSchema(schema); + } +} + +customElements.get("jsonschema-input") || customElements.define("jsonschema-input", JSONSchemaInput); diff --git a/src/electron/frontend/core/components/pages/Page.js b/src/electron/frontend/core/components/pages/Page.js index 3fc20c7197..ec35a88ffb 100644 --- a/src/electron/frontend/core/components/pages/Page.js +++ b/src/electron/frontend/core/components/pages/Page.js @@ -1,280 +1,280 @@ -import { LitElement, html } from "lit"; -import { runConversion } from "./guided-mode/options/utils.js"; -import { get, save } from "../../progress/index.js"; - -import { dismissNotification, notify } from "../../dependencies.js"; -import { isStorybook } from "../../globals.js"; - -import { randomizeElements, mapSessions, merge } from "./utils"; - -import { resolveMetadata } from "./guided-mode/data/utils.js"; -import Swal from "sweetalert2"; -import { createProgressPopup } from "../utils/progress.js"; - -export class Page extends LitElement { - // static get styles() { - // return useGlobalStyles( - // componentCSS, - // (sheet) => sheet.href && sheet.href.includes("bootstrap"), - // this.shadowRoot - // ); - // } - - info = { globalState: {} }; - - constructor(info = {}) { - super(); - Object.assign(this.info, info); - } - - createRenderRoot() { - return this; - } - - query = (input) => { - return (this.shadowRoot ?? this).querySelector(input); - }; - - onSet = () => {}; // User-defined function - - set = (info, rerender = true) => { - if (info) { - Object.assign(this.info, info); - this.onSet(); - if (rerender) this.requestUpdate(); - } - }; - - #notifications = []; - - dismiss = (notification) => { - if (notification) dismissNotification(notification); - else { - this.#notifications.forEach((notification) => dismissNotification(notification)); - this.#notifications = []; - } - }; - - notify = (...args) => { - const ref = notify(...args); - this.#notifications.push(ref); - return ref; - }; - - to = async (transition) => { - // Otherwise note unsaved updates if present - if ( - this.unsavedUpdates || - ("states" in this.info && - transition === 1 && // Only ensure save for standard forward progression - !this.info.states.saved) - ) { - if (transition === 1) - await this.save(); // Save before a single forward transition - else { - await Swal.fire({ - title: "You have unsaved data on this page.", - text: "Would you like to save your changes?", - icon: "warning", - showCancelButton: true, - confirmButtonColor: "#3085d6", - confirmButtonText: "Save and Continue", - cancelButtonText: "Ignore Changes", - }).then(async (result) => { - if (result && result.isConfirmed) await this.save(); - }); - } - } - - return await this.onTransition(transition); - }; - - onTransition = () => {}; // User-defined function - updatePages = () => {}; // User-defined function - beforeSave = () => {}; // User-defined function - - save = async (overrides, runBeforeSave = true) => { - if (runBeforeSave) await this.beforeSave(); - save(this, overrides); - if ("states" in this.info) this.info.states.saved = true; - this.unsavedUpdates = false; - }; - - load = (datasetNameToResume = new URLSearchParams(window.location.search).get("project")) => - (this.info.globalState = get(datasetNameToResume)); - - addSession({ subject, session, info }) { - if (!this.info.globalState.results[subject]) this.info.globalState.results[subject] = {}; - if (this.info.globalState.results[subject][session]) - throw new Error(`Session ${subject}/${session} already exists.`); - info = this.info.globalState.results[subject][session] = info ?? {}; - if (!info.metadata) info.metadata = {}; - if (!info.source_data) info.source_data = {}; - return info; - } - - removeSession({ subject, session }) { - delete this.info.globalState.results[subject][session]; - } - - mapSessions = (callback, data = this.info.globalState.results) => mapSessions(callback, data); - - async convert({ preview } = {}) { - const key = preview ? "preview" : "conversion"; - - delete this.info.globalState[key]; // Clear the preview results - - if (preview) { - const stubs = await this.runConversions({ stub_test: true }, undefined, { - title: "Creating conversion preview for all sessions...", - }); - this.info.globalState[key] = { stubs }; - } else { - this.info.globalState[key] = await this.runConversions({}, true, { title: "Running all conversions" }); - } - - this.unsavedUpdates = true; - - // Indicate conversion has run successfully - const { desyncedData } = this.info.globalState; - if (!desyncedData) this.info.globalState.desyncedData = {}; - - if (desyncedData) { - desyncedData[key] = false; - await this.save({}, false); - } - } - - async runConversions(conversionOptions = {}, toRun, options = {}) { - let original = toRun; - if (!Array.isArray(toRun)) toRun = this.mapSessions(); - - // Filter the sessions to run - if (typeof original === "number") - toRun = randomizeElements(toRun, original); // Grab a random set of sessions - else if (typeof original === "string") toRun = toRun.filter(({ subject }) => subject === original); - else if (typeof original === "function") toRun = toRun.filter(original); - - const results = {}; - - const isMultiple = toRun.length > 1; - - const swalOpts = await createProgressPopup({ title: `Running conversion`, ...options }); - const { close: closeProgressPopup, elements } = swalOpts; - - elements.container.insertAdjacentHTML( - "beforeend", - `Note: This may take a while to complete...
` - ); - - let completed = 0; - elements.progress.format = { n: completed, total: toRun.length }; - - for (let info of toRun) { - const { subject, session, globalState = this.info.globalState } = info; - const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`; - - const { conversion_output_folder, name, SourceData, alignment } = globalState.project; - - const sessionResults = globalState.results[subject][session]; - - const sourceDataCopy = structuredClone(sessionResults.source_data); - - // Resolve the correct session info from all of the metadata for this conversion - const sessionInfo = { - ...sessionResults, - metadata: resolveMetadata(subject, session, globalState), - source_data: merge(SourceData, sourceDataCopy), - }; - - const result = await runConversion( - { - output_folder: conversionOptions.stub_test ? undefined : conversion_output_folder, - project_name: name, - nwbfile_path: file, - overwrite: true, // We assume override is true because the native NWB file dialog will not allow the user to select an existing file (unless they approve the overwrite) - ...sessionInfo, // source_data and metadata are passed in here - ...conversionOptions, // Any additional conversion options override the defaults - - interfaces: globalState.interfaces, - alignment - }, - swalOpts - ).catch((error) => { - let message = error.message; - - if (message.includes("The user aborted a request.")) { - this.notify("Conversion was cancelled.", "warning"); - throw error; - } - - this.notify(message, "error"); - closeProgressPopup(); - throw error; - }); - - completed++; - if (isMultiple) { - const progressInfo = { n: completed, total: toRun.length }; - elements.progress.format = progressInfo; - } - - const subRef = results[subject] ?? (results[subject] = {}); - subRef[session] = result; - } - - closeProgressPopup(); - elements.container.style.textAlign = ""; // Clear style update - - return results; - } - - // NOTE: Until the shadow DOM is supported in Storybook, we can't use this render function how we'd intend to. - addPage = (id, subpage) => { - if (!this.info.pages) this.info.pages = {}; - this.info.pages[id] = subpage; - this.updatePages(); - }; - - checkSyncState = async (info = this.info, sync = info.sync) => { - if (!sync) return; - if (isStorybook) return; - - const { desyncedData } = info.globalState; - - return Promise.all( - sync.map((k) => { - if (desyncedData?.[k] !== false) { - if (k === "conversion") return this.convert(); - else if (k === "preview") return this.convert({ preview: true }); - } - }) - ); - }; - - updateSections = () => { - const dashboard = document.querySelector("nwb-dashboard"); - dashboard.updateSections({ sidebar: true, main: true }, this.info.globalState); - }; - - #unsaved = false; - get unsavedUpdates() { - return this.#unsaved; - } - - set unsavedUpdates(value) { - this.#unsaved = !!value; - if (value === "conversions") this.info.globalState.desyncedData = { preview: true, conversion: true }; - } - - // NOTE: Make sure you call this explicitly if a child class overwrites this AND data is updated - updated() { - this.unsavedUpdates = false; - } - - render() { - return html``; - } -} - -customElements.get("nwbguide-page") || customElements.define("nwbguide-page", Page); +import { LitElement, html } from "lit"; +import { runConversion } from "./guided-mode/options/utils.js"; +import { get, save } from "../../progress/index.js"; + +import { dismissNotification, notify } from "../../dependencies.js"; +import { isStorybook } from "../../globals.js"; + +import { randomizeElements, mapSessions, merge } from "./utils"; + +import { resolveMetadata } from "./guided-mode/data/utils.js"; +import Swal from "sweetalert2"; +import { createProgressPopup } from "../utils/progress.js"; + +export class Page extends LitElement { + // static get styles() { + // return useGlobalStyles( + // componentCSS, + // (sheet) => sheet.href && sheet.href.includes("bootstrap"), + // this.shadowRoot + // ); + // } + + info = { globalState: {} }; + + constructor(info = {}) { + super(); + Object.assign(this.info, info); + } + + createRenderRoot() { + return this; + } + + query = (input) => { + return (this.shadowRoot ?? this).querySelector(input); + }; + + onSet = () => {}; // User-defined function + + set = (info, rerender = true) => { + if (info) { + Object.assign(this.info, info); + this.onSet(); + if (rerender) this.requestUpdate(); + } + }; + + #notifications = []; + + dismiss = (notification) => { + if (notification) dismissNotification(notification); + else { + this.#notifications.forEach((notification) => dismissNotification(notification)); + this.#notifications = []; + } + }; + + notify = (...args) => { + const ref = notify(...args); + this.#notifications.push(ref); + return ref; + }; + + to = async (transition) => { + // Otherwise note unsaved updates if present + if ( + this.unsavedUpdates || + ("states" in this.info && + transition === 1 && // Only ensure save for standard forward progression + !this.info.states.saved) + ) { + if (transition === 1) + await this.save(); // Save before a single forward transition + else { + await Swal.fire({ + title: "You have unsaved data on this page.", + text: "Would you like to save your changes?", + icon: "warning", + showCancelButton: true, + confirmButtonColor: "#3085d6", + confirmButtonText: "Save and Continue", + cancelButtonText: "Ignore Changes", + }).then(async (result) => { + if (result && result.isConfirmed) await this.save(); + }); + } + } + + return await this.onTransition(transition); + }; + + onTransition = () => {}; // User-defined function + updatePages = () => {}; // User-defined function + beforeSave = () => {}; // User-defined function + + save = async (overrides, runBeforeSave = true) => { + if (runBeforeSave) await this.beforeSave(); + save(this, overrides); + if ("states" in this.info) this.info.states.saved = true; + this.unsavedUpdates = false; + }; + + load = (datasetNameToResume = new URLSearchParams(window.location.search).get("project")) => + (this.info.globalState = get(datasetNameToResume)); + + addSession({ subject, session, info }) { + if (!this.info.globalState.results[subject]) this.info.globalState.results[subject] = {}; + if (this.info.globalState.results[subject][session]) + throw new Error(`Session ${subject}/${session} already exists.`); + info = this.info.globalState.results[subject][session] = info ?? {}; + if (!info.metadata) info.metadata = {}; + if (!info.source_data) info.source_data = {}; + return info; + } + + removeSession({ subject, session }) { + delete this.info.globalState.results[subject][session]; + } + + mapSessions = (callback, data = this.info.globalState.results) => mapSessions(callback, data); + + async convert({ preview } = {}) { + const key = preview ? "preview" : "conversion"; + + delete this.info.globalState[key]; // Clear the preview results + + if (preview) { + const stubs = await this.runConversions({ stub_test: true }, undefined, { + title: "Creating conversion preview for all sessions...", + }); + this.info.globalState[key] = { stubs }; + } else { + this.info.globalState[key] = await this.runConversions({}, true, { title: "Running all conversions" }); + } + + this.unsavedUpdates = true; + + // Indicate conversion has run successfully + const { desyncedData } = this.info.globalState; + if (!desyncedData) this.info.globalState.desyncedData = {}; + + if (desyncedData) { + desyncedData[key] = false; + await this.save({}, false); + } + } + + async runConversions(conversionOptions = {}, toRun, options = {}) { + let original = toRun; + if (!Array.isArray(toRun)) toRun = this.mapSessions(); + + // Filter the sessions to run + if (typeof original === "number") + toRun = randomizeElements(toRun, original); // Grab a random set of sessions + else if (typeof original === "string") toRun = toRun.filter(({ subject }) => subject === original); + else if (typeof original === "function") toRun = toRun.filter(original); + + const results = {}; + + const isMultiple = toRun.length > 1; + + const swalOpts = await createProgressPopup({ title: `Running conversion`, ...options }); + const { close: closeProgressPopup, elements } = swalOpts; + + elements.container.insertAdjacentHTML( + "beforeend", + `Note: This may take a while to complete...
` + ); + + let completed = 0; + elements.progress.format = { n: completed, total: toRun.length }; + + for (let info of toRun) { + const { subject, session, globalState = this.info.globalState } = info; + const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`; + + const { conversion_output_folder, name, SourceData, alignment } = globalState.project; + + const sessionResults = globalState.results[subject][session]; + + const sourceDataCopy = structuredClone(sessionResults.source_data); + + // Resolve the correct session info from all of the metadata for this conversion + const sessionInfo = { + ...sessionResults, + metadata: resolveMetadata(subject, session, globalState), + source_data: merge(SourceData, sourceDataCopy), + }; + + const result = await runConversion( + { + output_folder: conversionOptions.stub_test ? undefined : conversion_output_folder, + project_name: name, + nwbfile_path: file, + overwrite: true, // We assume override is true because the native NWB file dialog will not allow the user to select an existing file (unless they approve the overwrite) + ...sessionInfo, // source_data and metadata are passed in here + ...conversionOptions, // Any additional conversion options override the defaults + + interfaces: globalState.interfaces, + alignment, + }, + swalOpts + ).catch((error) => { + let message = error.message; + + if (message.includes("The user aborted a request.")) { + this.notify("Conversion was cancelled.", "warning"); + throw error; + } + + this.notify(message, "error"); + closeProgressPopup(); + throw error; + }); + + completed++; + if (isMultiple) { + const progressInfo = { n: completed, total: toRun.length }; + elements.progress.format = progressInfo; + } + + const subRef = results[subject] ?? (results[subject] = {}); + subRef[session] = result; + } + + closeProgressPopup(); + elements.container.style.textAlign = ""; // Clear style update + + return results; + } + + // NOTE: Until the shadow DOM is supported in Storybook, we can't use this render function how we'd intend to. + addPage = (id, subpage) => { + if (!this.info.pages) this.info.pages = {}; + this.info.pages[id] = subpage; + this.updatePages(); + }; + + checkSyncState = async (info = this.info, sync = info.sync) => { + if (!sync) return; + if (isStorybook) return; + + const { desyncedData } = info.globalState; + + return Promise.all( + sync.map((k) => { + if (desyncedData?.[k] !== false) { + if (k === "conversion") return this.convert(); + else if (k === "preview") return this.convert({ preview: true }); + } + }) + ); + }; + + updateSections = () => { + const dashboard = document.querySelector("nwb-dashboard"); + dashboard.updateSections({ sidebar: true, main: true }, this.info.globalState); + }; + + #unsaved = false; + get unsavedUpdates() { + return this.#unsaved; + } + + set unsavedUpdates(value) { + this.#unsaved = !!value; + if (value === "conversions") this.info.globalState.desyncedData = { preview: true, conversion: true }; + } + + // NOTE: Make sure you call this explicitly if a child class overwrites this AND data is updated + updated() { + this.unsavedUpdates = false; + } + + render() { + return html``; + } +} + +customElements.get("nwbguide-page") || customElements.define("nwbguide-page", Page); diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js index b7cc1ae2a4..e1fa9b226d 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js @@ -247,7 +247,7 @@ export class GuidedSourceDataPage extends ManagedPage { const { subject, session } = getInfoFromId(id); - this.dismiss() + this.dismiss(); const header = document.createElement("div"); Object.assign(header.style, { paddingTop: "10px" }); @@ -293,8 +293,11 @@ export class GuidedSourceDataPage extends ManagedPage { const { metadata } = data; if (Object.keys(metadata).length === 0) { - this.notify(`

Time Alignment Failed

Please ensure that all source data is specified.`, "error"); - return false + this.notify( + `

Time Alignment Failed

Please ensure that all source data is specified.`, + "error" + ); + return false; } alignment = new TimeAlignment({ @@ -306,7 +309,7 @@ export class GuidedSourceDataPage extends ManagedPage { modal.innerHTML = ""; modal.append(alignment); - return true + return true; }, }); @@ -348,4 +351,4 @@ export class GuidedSourceDataPage extends ManagedPage { } customElements.get("nwbguide-guided-sourcedata-page") || - customElements.define("nwbguide-guided-sourcedata-page", GuidedSourceDataPage); \ No newline at end of file + customElements.define("nwbguide-guided-sourcedata-page", GuidedSourceDataPage); diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js index 47cc6dbed4..68feb0db98 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,273 +1,273 @@ -import { LitElement, css } from "lit"; -import { JSONSchemaInput } from "../../../../JSONSchemaInput"; -import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; - -const options = { - start: { - name: "Adjust Start Time", - schema: { - type: "number", - description: "The start time of the recording in seconds.", - min: 0, - }, - }, - timestamps: { - name: "Upload Timestamps", - schema: { - type: "string", - format: "file", - description: "A CSV file containing the timestamps of the recording.", - }, - }, - linked: { - name: "Link to Recording", - schema: { - type: "string", - description: "The name of the linked recording.", - placeholder: "Select a recording interface", - enum: [], - strict: true, - }, - }, -}; - -export class TimeAlignment extends LitElement { - static get styles() { - return css` - * { - box-sizing: border-box; - } - - :host { - display: block; - padding: 20px; - } - - :host > div { - display: flex; - flex-direction: column; - gap: 10px; - } - - :host > div > div { - display: flex; - align-items: center; - gap: 20px; - } - - :host > div > div > *:nth-child(1) { - width: 100%; - } - - :host > div > div > *:nth-child(2) { - display: flex; - flex-direction: column; - justify-content: center; - white-space: nowrap; - font-size: 90%; - min-width: 150px; - } - - :host > div > div > *:nth-child(2) > div { - cursor: pointer; - padding: 5px 10px; - border: 1px solid lightgray; - } - - :host > div > div > *:nth-child(3) { - width: 700px; - } - - .disclaimer { - font-size: 90%; - color: gray; - } - - label { - font-weight: bold; - } - - [selected] { - font-weight: bold; - background: whitesmoke; - } - `; - } - - static get properties() { - return { - data: { type: Object }, - }; - } - - constructor({ data = {}, results = {}, interfaces = {} }) { - super(); - this.data = data; - this.results = results; - this.interfaces = interfaces; - } - - render() { - const container = document.createElement("div"); - - const { timestamps, errors, metadata } = this.data; - - const flatTimes = Object.values(timestamps) - .map((interfaceTimestamps) => { - return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; - }) - .flat() - .filter((timestamp) => !isNaN(timestamp)); - - const minTime = Math.min(...flatTimes); - const maxTime = Math.max(...flatTimes); - - const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); - const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; - - const cachedErrors = {}; - - for (let name in timestamps) { - cachedErrors[name] = {}; - - if (!(name in this.results)) - this.results[name] = { - selected: undefined, - values: {}, - }; - - const row = document.createElement("div"); - // Object.assign(row.style, { - // display: 'flex', - // alignItems: 'center', - // justifyContent: 'space-between', - // gap: '10px', - // }); - - const barCell = document.createElement("div"); - - const label = document.createElement("label"); - label.innerText = name; - barCell.append(label); - - const info = timestamps[name]; - - const barContainer = document.createElement("div"); - Object.assign(barContainer.style, { - height: "10px", - width: "100%", - marginTop: "5px", - border: "1px solid lightgray", - position: "relative", - }); - - barCell.append(barContainer); - - const isSortingInterface = metadata[name].sorting === true; - const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0; - - // Render this way if the interface has data - if (info.length > 0) { - const firstTime = info[0]; - const lastTime = info[info.length - 1]; - - const smallLabel = document.createElement("small"); - smallLabel.innerText = `${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec`; - - const firstTimePct = normalizeTimePct(firstTime); - const lastTimePct = normalizeTimePct(lastTime); - - const width = `calc(${lastTimePct} - ${firstTimePct})`; - - const bar = document.createElement("div"); - - Object.assign(bar.style, { - position: "absolute", - left: firstTimePct, - width: width, - height: "100%", - background: "#029CFD", - }); - - barContainer.append(bar); - barCell.append(smallLabel); - } else { - barContainer.style.background = - "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; - } - - row.append(barCell); - - const selectionCell = document.createElement("div"); - const resultCell = document.createElement("div"); - - const optionsCopy = Object.entries(structuredClone(options)); - - optionsCopy[2][1].schema.enum = Object.keys(timestamps).filter((str) => - this.interfaces[str].includes("Recording") - ); - - const resolvedOptionEntries = hasCompatibleInterfaces ? optionsCopy : optionsCopy.slice(0, 2); - - const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { - const optionResults = this.results[name]; - - const clickableElement = document.createElement("div"); - clickableElement.innerText = option.name; - clickableElement.onclick = () => { - optionResults.selected = selected; - - Object.values(elements).forEach((el) => el.removeAttribute("selected")); - clickableElement.setAttribute("selected", ""); - - const element = new JSONSchemaInput({ - value: optionResults.values[selected], - schema: option.schema, - path: [], - controls: option.controls ? option.controls() : [], - onUpdate: (value) => (optionResults.values[selected] = value), - }); - - resultCell.innerHTML = ""; - resultCell.append(element); - - const errorMessage = cachedErrors[name][selected]; - if (errorMessage) { - const error = new InspectorListItem({ - type: "error", - message: `

Alignment Failed

${errorMessage}`, - }); - - error.style.marginTop = "5px"; - resultCell.append(error); - } - }; - - acc[selected] = clickableElement; - return acc; - }, {}); - - const elArray = Object.values(elements); - selectionCell.append(...elArray); - - const selected = this.results[name].selected; - if (errors[name]) cachedErrors[name][selected] = errors[name]; - - row.append(selectionCell, resultCell); - if (selected) elements[selected].click(); - else elArray[0].click(); - - // const empty = document.createElement("div"); - // const disclaimer = document.createElement("div"); - // disclaimer.classList.add("disclaimer"); - // disclaimer.innerText = "Edit in Source Data"; - // row.append(disclaimer, empty); - - container.append(row); - } - - return container; - } -} - -customElements.get("nwbguide-time-alignment") || customElements.define("nwbguide-time-alignment", TimeAlignment); +import { LitElement, css } from "lit"; +import { JSONSchemaInput } from "../../../../JSONSchemaInput"; +import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; + +const options = { + start: { + name: "Adjust Start Time", + schema: { + type: "number", + description: "The start time of the recording in seconds.", + min: 0, + }, + }, + timestamps: { + name: "Upload Timestamps", + schema: { + type: "string", + format: "file", + description: "A CSV file containing the timestamps of the recording.", + }, + }, + linked: { + name: "Link to Recording", + schema: { + type: "string", + description: "The name of the linked recording.", + placeholder: "Select a recording interface", + enum: [], + strict: true, + }, + }, +}; + +export class TimeAlignment extends LitElement { + static get styles() { + return css` + * { + box-sizing: border-box; + } + + :host { + display: block; + padding: 20px; + } + + :host > div { + display: flex; + flex-direction: column; + gap: 10px; + } + + :host > div > div { + display: flex; + align-items: center; + gap: 20px; + } + + :host > div > div > *:nth-child(1) { + width: 100%; + } + + :host > div > div > *:nth-child(2) { + display: flex; + flex-direction: column; + justify-content: center; + white-space: nowrap; + font-size: 90%; + min-width: 150px; + } + + :host > div > div > *:nth-child(2) > div { + cursor: pointer; + padding: 5px 10px; + border: 1px solid lightgray; + } + + :host > div > div > *:nth-child(3) { + width: 700px; + } + + .disclaimer { + font-size: 90%; + color: gray; + } + + label { + font-weight: bold; + } + + [selected] { + font-weight: bold; + background: whitesmoke; + } + `; + } + + static get properties() { + return { + data: { type: Object }, + }; + } + + constructor({ data = {}, results = {}, interfaces = {} }) { + super(); + this.data = data; + this.results = results; + this.interfaces = interfaces; + } + + render() { + const container = document.createElement("div"); + + const { timestamps, errors, metadata } = this.data; + + const flatTimes = Object.values(timestamps) + .map((interfaceTimestamps) => { + return [interfaceTimestamps[0], interfaceTimestamps.slice(-1)[0]]; + }) + .flat() + .filter((timestamp) => !isNaN(timestamp)); + + const minTime = Math.min(...flatTimes); + const maxTime = Math.max(...flatTimes); + + const normalizeTime = (time) => (time - minTime) / (maxTime - minTime); + const normalizeTimePct = (time) => `${normalizeTime(time) * 100}%`; + + const cachedErrors = {}; + + for (let name in timestamps) { + cachedErrors[name] = {}; + + if (!(name in this.results)) + this.results[name] = { + selected: undefined, + values: {}, + }; + + const row = document.createElement("div"); + // Object.assign(row.style, { + // display: 'flex', + // alignItems: 'center', + // justifyContent: 'space-between', + // gap: '10px', + // }); + + const barCell = document.createElement("div"); + + const label = document.createElement("label"); + label.innerText = name; + barCell.append(label); + + const info = timestamps[name]; + + const barContainer = document.createElement("div"); + Object.assign(barContainer.style, { + height: "10px", + width: "100%", + marginTop: "5px", + border: "1px solid lightgray", + position: "relative", + }); + + barCell.append(barContainer); + + const isSortingInterface = metadata[name].sorting === true; + const hasCompatibleInterfaces = isSortingInterface && metadata[name].compatible.length > 0; + + // Render this way if the interface has data + if (info.length > 0) { + const firstTime = info[0]; + const lastTime = info[info.length - 1]; + + const smallLabel = document.createElement("small"); + smallLabel.innerText = `${firstTime.toFixed(2)} - ${lastTime.toFixed(2)} sec`; + + const firstTimePct = normalizeTimePct(firstTime); + const lastTimePct = normalizeTimePct(lastTime); + + const width = `calc(${lastTimePct} - ${firstTimePct})`; + + const bar = document.createElement("div"); + + Object.assign(bar.style, { + position: "absolute", + left: firstTimePct, + width: width, + height: "100%", + background: "#029CFD", + }); + + barContainer.append(bar); + barCell.append(smallLabel); + } else { + barContainer.style.background = + "repeating-linear-gradient(45deg, lightgray, lightgray 10px, white 10px, white 20px)"; + } + + row.append(barCell); + + const selectionCell = document.createElement("div"); + const resultCell = document.createElement("div"); + + const optionsCopy = Object.entries(structuredClone(options)); + + optionsCopy[2][1].schema.enum = Object.keys(timestamps).filter((str) => + this.interfaces[str].includes("Recording") + ); + + const resolvedOptionEntries = hasCompatibleInterfaces ? optionsCopy : optionsCopy.slice(0, 2); + + const elements = resolvedOptionEntries.reduce((acc, [selected, option]) => { + const optionResults = this.results[name]; + + const clickableElement = document.createElement("div"); + clickableElement.innerText = option.name; + clickableElement.onclick = () => { + optionResults.selected = selected; + + Object.values(elements).forEach((el) => el.removeAttribute("selected")); + clickableElement.setAttribute("selected", ""); + + const element = new JSONSchemaInput({ + value: optionResults.values[selected], + schema: option.schema, + path: [], + controls: option.controls ? option.controls() : [], + onUpdate: (value) => (optionResults.values[selected] = value), + }); + + resultCell.innerHTML = ""; + resultCell.append(element); + + const errorMessage = cachedErrors[name][selected]; + if (errorMessage) { + const error = new InspectorListItem({ + type: "error", + message: `

Alignment Failed

${errorMessage}`, + }); + + error.style.marginTop = "5px"; + resultCell.append(error); + } + }; + + acc[selected] = clickableElement; + return acc; + }, {}); + + const elArray = Object.values(elements); + selectionCell.append(...elArray); + + const selected = this.results[name].selected; + if (errors[name]) cachedErrors[name][selected] = errors[name]; + + row.append(selectionCell, resultCell); + if (selected) elements[selected].click(); + else elArray[0].click(); + + // const empty = document.createElement("div"); + // const disclaimer = document.createElement("div"); + // disclaimer.classList.add("disclaimer"); + // disclaimer.innerText = "Edit in Source Data"; + // row.append(disclaimer, empty); + + container.append(row); + } + + return container; + } +} + +customElements.get("nwbguide-time-alignment") || customElements.define("nwbguide-time-alignment", TimeAlignment);