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``;
- }
- } 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``;
+ }
+ } 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);