diff --git a/schemas/json/dandi/global.json b/schemas/json/dandi/global.json index 7b72776b5..081eae1f1 100644 --- a/schemas/json/dandi/global.json +++ b/schemas/json/dandi/global.json @@ -16,5 +16,6 @@ }, "required": ["main_api_key"] } - } + }, + "required": ["api_keys"] } diff --git a/src/renderer/src/stories/Accordion.js b/src/renderer/src/stories/Accordion.js index cae120639..92e6e8c95 100644 --- a/src/renderer/src/stories/Accordion.js +++ b/src/renderer/src/stories/Accordion.js @@ -23,6 +23,11 @@ export class Accordion extends LitElement { box-sizing: border-box; } + :host { + display: block; + overflow: hidden; + } + .header { display: flex; align-items: end; @@ -107,10 +112,14 @@ export class Accordion extends LitElement { .guided--nav-bar-dropdown::after { font-size: 0.8em; position: absolute; - right: 50px; + right: 25px; font-family: ${unsafeCSS(emojiFontFamily)}; } + .guided--nav-bar-dropdown.toggleable::after { + right: 50px; + } + .guided--nav-bar-dropdown.error::after { content: "${errorSymbol}"; } @@ -123,7 +132,7 @@ export class Accordion extends LitElement { content: "${successSymbol}"; } - .guided--nav-bar-dropdown:hover { + .guided--nav-bar-dropdown.toggleable:hover { cursor: pointer; background-color: lightgray; } @@ -143,34 +152,54 @@ export class Accordion extends LitElement { padding-left: 0px; overflow-y: auto; } + + .disabled { + opacity: 0.5; + pointer-events: none; + } `; } static get properties() { return { - sections: { type: Object, reflect: false }, + name: { type: String, reflect: true }, + open: { type: Boolean, reflect: true }, + disabled: { type: Boolean, reflect: true }, + status: { type: String, reflect: true }, }; } - constructor({ sections = {}, contentPadding } = {}) { + constructor({ + name, + subtitle, + toggleable = true, + content, + open = false, + status, + disabled = false, + contentPadding, + } = {}) { super(); - this.sections = sections; + this.name = name; + this.subtitle = subtitle; + this.content = content; + this.open = open; + this.status = status; + this.disabled = disabled; + this.toggleable = toggleable; this.contentPadding = contentPadding; } updated() { - Object.entries(this.sections).map(([sectionName, info]) => { - const isActive = info.open; - if (isActive) this.#toggleDropdown(sectionName, true); - else this.#toggleDropdown(sectionName, false); - }); + if (!this.content) return; + this.toggle(!!this.open); } - setSectionStatus = (sectionName, status) => { - const el = this.shadowRoot.querySelector("[data-section-name='" + sectionName + "']"); + setStatus = (status) => { + const el = this.shadowRoot.getElementById("dropdown"); el.classList.remove("error", "warning", "valid"); el.classList.add(status); - this.sections[sectionName].status = status; + this.status = status; }; onClick = () => {}; // Set by the user @@ -183,60 +212,67 @@ export class Accordion extends LitElement { } }; - #toggleDropdown = (sectionName, forcedState) => { + toggle = (forcedState) => { const hasForce = forcedState !== undefined; - const toggledState = !this.sections[sectionName].open; + const toggledState = !this.open; - let state = hasForce ? forcedState : toggledState; + const desiredState = hasForce ? forcedState : toggledState; + const state = this.toOpen(desiredState); //remove hidden from child elements with guided--nav-bar-section-page class - const section = this.shadowRoot.querySelector("[data-section='" + sectionName + "']"); + const section = this.shadowRoot.getElementById("section"); section.toggleAttribute("hidden", hasForce ? !state : undefined); - const dropdown = this.shadowRoot.querySelector("[data-section-name='" + sectionName + "']"); + const dropdown = this.shadowRoot.getElementById("dropdown"); this.#updateClass("active", dropdown, !state); //toggle the chevron const chevron = dropdown.querySelector("nwb-chevron"); - chevron.direction = state ? "bottom" : "right"; + if (chevron) chevron.direction = state ? "bottom" : "right"; - this.sections[sectionName].open = state; + if (desiredState === state) this.open = state; // Update state if not overridden + }; + + toOpen = (state = this.open) => { + if (!this.toggleable) return true; // Force open if not toggleable + else if (this.disabled) return false; // Force closed if disabled + return state; }; render() { + const isToggleable = this.content && this.toggleable; + return html` - +
+ + ${this.content + ? html`` + : ""} +
`; } } diff --git a/src/renderer/src/stories/InstanceManager.js b/src/renderer/src/stories/InstanceManager.js index 3336dec28..dad1b7d31 100644 --- a/src/renderer/src/stories/InstanceManager.js +++ b/src/renderer/src/stories/InstanceManager.js @@ -124,6 +124,10 @@ export class InstanceManager extends LitElement { #new-info > input { margin-right: 10px; } + + nwb-accordion { + margin-bottom: 0.5em; + } `; } @@ -161,7 +165,7 @@ export class InstanceManager extends LitElement { const id = path.slice(0, i + 1).join("/"); const accordion = this.#accordions[id]; target = target[path[i]]; // Progressively check the deeper nested instances - if (accordion) accordion.setSectionStatus(id, checkStatus(false, false, [...Object.values(target)])); + if (accordion) accordion.setStatus(checkStatus(false, false, [...Object.values(target)])); } }; @@ -299,11 +303,8 @@ export class InstanceManager extends LitElement { const list = this.#render(value, [...path, key]); const accordion = new Accordion({ - sections: { - [key]: { - content: list, - }, - }, + name: key, + content: list, contentPadding: "10px", }); diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index e3c0b03f2..04bc6d906 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -12,6 +12,10 @@ import { resolveProperties } from "./pages/guided-mode/data/utils"; import { JSONSchemaInput } from "./JSONSchemaInput"; import { InspectorListItem } from "./preview/inspector/InspectorList"; +const isObject = (o) => { + return o && typeof o === "object" && !Array.isArray(o); +}; + const selfRequiredSymbol = Symbol(); const componentCSS = ` @@ -146,7 +150,11 @@ const componentCSS = ` line-height: 1.4285em; } - [disabled] { + nwb-accordion { + margin-bottom: 0.5em; + } + + [disabled]{ opacity: 0.5; pointer-events: none; } @@ -164,7 +172,6 @@ export class JSONSchemaForm extends LitElement { static get properties() { return { - mode: { type: String, reflect: true }, schema: { type: Object, reflect: false }, results: { type: Object, reflect: false }, required: { type: Object, reflect: false }, @@ -192,21 +199,16 @@ export class JSONSchemaForm extends LitElement { resolved = {}; // Keep track of actual resolved values—not just what the user provides as results - states = {}; - constructor(props = {}) { super(); this.#rendered = this.#updateRendered(true); this.identifier = props.identifier; - this.mode = props.mode ?? "default"; this.schema = props.schema ?? {}; this.results = (props.base ? structuredClone(props.results) : props.results) ?? {}; // Deep clone results in nested forms this.globals = props.globals ?? {}; - this.states = props.states ?? {}; // Accordion and other states - this.ignore = props.ignore ?? []; this.required = props.required ?? {}; this.dialogOptions = props.dialogOptions; @@ -239,15 +241,15 @@ export class JSONSchemaForm extends LitElement { getTable = (path) => { if (typeof path === "string") path = path.split("."); + if (path.length === 1) return this.tables[path[0]]; // return table if accessible const copy = [...path]; const tableName = copy.pop(); - if (this.mode === "accordion") return this.getForm(copy).getTable(tableName); - else return this.shadowRoot.getElementById(path.join("-")).children[1].shadowRoot.children[0]; // Get table from UI container, then JSONSchemaInput + return this.getForm(copy).getTable(tableName); }; - + v; getForm = (path) => { if (typeof path === "string") path = path.split("."); const form = this.#nestedForms[path[0]]; @@ -258,9 +260,11 @@ export class JSONSchemaForm extends LitElement { getInput = (path) => { if (typeof path === "string") path = path.split("."); + const container = this.shadowRoot.querySelector(`#${path.join("-")}`); - if (!container) return; - return container.querySelector("jsonschema-input"); + + if (!container) return this.getForm(path[0]).getInput(path.slice(1)); + return container?.querySelector("jsonschema-input"); }; #requirements = {}; @@ -277,7 +281,8 @@ export class JSONSchemaForm extends LitElement { } // Track resolved values for the form (data only) - updateData(localPath, value) { + updateData(localPath, value, forceUpdate = false) { + if (!value) throw new Error("Cannot update data with undefined value"); const path = [...localPath]; const name = path.pop(); @@ -291,8 +296,6 @@ export class JSONSchemaForm extends LitElement { // NOTE: Forms with nested forms will handle their own state updates if (this.isUndefined(value)) { - const globalValue = this.getGlobalValue(localPath); - // Continue to resolve and re-render... if (globalValue) { value = resolvedParent[name] = globalValue; @@ -306,10 +309,11 @@ export class JSONSchemaForm extends LitElement { resultParent[name] = undefined; // NOTE: Will be removed when stringified } else { resultParent[name] = value === globalValue ? undefined : value; // Retain association with global value - resolvedParent[name] = value; + resolvedParent[name] = + isObject(value) && isObject(resolvedParent) ? merge(value, resolvedParent[name]) : value; // Merge with existing resolved values } - if (hasUpdate) this.onUpdate(localPath, value); // Ensure the value has actually changed + if (hasUpdate || forceUpdate) this.onUpdate(localPath, value); // Ensure the value has actually changed } #addMessage = (name, message, type) => { @@ -334,11 +338,17 @@ export class JSONSchemaForm extends LitElement { }; status; - checkStatus = () => + checkStatus = () => { checkStatus.call(this, this.#nWarnings, this.#nErrors, [ - ...Object.values(this.#nestedForms), + ...Object.entries(this.#nestedForms) + .filter(([k, v]) => { + const accordion = this.#accordions[k]; + return !accordion || !accordion.disabled; + }) + .map(([_, v]) => v), ...Object.values(this.tables), ]); + }; throw = (message) => { this.onThrow(message, this.identifier); @@ -350,6 +360,7 @@ export class JSONSchemaForm extends LitElement { const requiredButNotSpecified = await this.#validateRequirements(resolved); // get missing required paths const isValid = !requiredButNotSpecified.length; + // Check if all inputs are valid const flaggedInputs = this.shadowRoot ? this.shadowRoot.querySelectorAll(".invalid") : []; const allErrors = Array.from(flaggedInputs) @@ -362,23 +373,40 @@ export class JSONSchemaForm extends LitElement { return (acc += curr.includes(this.#isARequiredPropertyString) ? 1 : 0); }, 0); - console.log(allErrors); - // Print out a detailed error message if any inputs are missing - let message = ""; - if (!isValid && allErrors.length && nMissingRequired === allErrors.length) - message = `${nMissingRequired} required inputs are not defined.`; + let message = isValid + ? "" + : requiredButNotSpecified.length === 1 + ? `${requiredButNotSpecified[0]} is not defined` + : `${requiredButNotSpecified.length} required inputs are not specified properly`; + if (requiredButNotSpecified.length !== nMissingRequired) + console.warn("Disagreement about the correct error to throw..."); + + // if (!isValid && allErrors.length && nMissingRequired === allErrors.length) message = `${nMissingRequired} required inputs are not defined.`; // Check if all inputs are valid if (flaggedInputs.length) { flaggedInputs[0].focus(); - if (!message) message = `${flaggedInputs.length} invalid form values.`; - message += ` Please check the highlighted fields.`; + if (!message) { + console.log(flaggedInputs); + if (flaggedInputs.length === 1) + message = `${header(flaggedInputs[0].path.join("."))} is not valid`; + else message = `${flaggedInputs.length} invalid form values`; + } + message += `${ + this.base.length ? ` in the ${this.base.join(".")} section` : "" + }. Please check the highlighted fields.`; } if (message) this.throw(message); - for (let key in this.#nestedForms) await this.#nestedForms[key].validate(resolved ? resolved[key] : undefined); // Validate nested forms too + // Validate nested forms (skip disabled) + for (let name in this.#nestedForms) { + const accordion = this.#accordions[name]; + if (!accordion || !accordion.disabled) + await this.#nestedForms[name].validate(resolved ? resolved[name] : undefined); // Validate nested forms too + } + try { for (let key in this.tables) await this.tables[key].validate(resolved ? resolved[key] : undefined); // Validate nested tables too } catch (e) { @@ -411,6 +439,7 @@ export class JSONSchemaForm extends LitElement { #get = (path, object = this.resolved, omitted = []) => { // path = path.slice(this.base.length); // Correct for base path + if (!path) throw new Error("Path not specified"); return path.reduce( (acc, curr) => (acc = acc?.[curr] ?? acc?.[omitted.find((str) => acc[str])]?.[curr]), object @@ -523,6 +552,8 @@ export class JSONSchemaForm extends LitElement { for (let name in requirements) { let isRequired = requirements[name]; + if (this.#accordions[name]?.disabled) continue; // Skip disabled accordions + // // NOTE: Uncomment to block checking requirements inside optional properties // if (!requirements[name][selfRequiredSymbol] && !resolved[name]) continue; // Do not continue checking requirements if absent and not required @@ -530,9 +561,10 @@ export class JSONSchemaForm extends LitElement { if (isRequired) { let path = parentPath ? `${parentPath}-${name}` : name; - if (typeof isRequired === "object" && !Array.isArray(isRequired)) - invalid.push(...(await this.#validateRequirements(resolved[name], isRequired, path))); - else if (this.isUndefined(resolved[name]) && this.validateEmptyValues) invalid.push(path); + // if (typeof isRequired === "object" && !Array.isArray(isRequired)) + // invalid.push(...(await this.#validateRequirements(resolved[name], isRequired, path))); + // else + if (this.isUndefined(resolved[name]) && this.validateEmptyValues) invalid.push(path); } } @@ -790,6 +822,8 @@ export class JSONSchemaForm extends LitElement { } }; + #accordions = {}; + #render = (schema, results, required = {}, path = []) => { let isLink = Symbol("isLink"); // Filter non-required properties (if specified) and render the sub-schema @@ -880,85 +914,170 @@ export class JSONSchemaForm extends LitElement { const localPath = [...path, name]; - if (this.mode === "accordion" && hasMany) { - const headerName = header(name); - - // Check properties that will be rendered before creating the accordion - const base = [...this.base, ...localPath]; - const renderable = this.#getRenderable(info, required[name], base); - - if (renderable.length) { - this.#nestedForms[name] = new JSONSchemaForm({ - identifier: this.identifier, - schema: info, - results: { ...results[name] }, - globals: this.globals?.[name], - - states: this.states, - - mode: this.mode, - - onUpdate: (internalPath, value) => { - const path = [...localPath, ...internalPath]; - this.updateData(path, value); - }, - - required: required[name], // Scoped to the sub-schema - ignore: this.ignore, - dialogOptions: this.dialogOptions, - dialogType: this.dialogType, - onlyRequired: this.onlyRequired, - showLevelOverride: this.showLevelOverride, - deferLoading: this.deferLoading, - conditionalRequirements: this.conditionalRequirements, - validateOnChange: (...args) => this.validateOnChange(...args), - onThrow: (...args) => this.onThrow(...args), - validateEmptyValues: this.validateEmptyValues, - onStatusChange: (status) => { - accordion.setSectionStatus(headerName, status); - this.checkStatus(); - }, // Forward status changes to the parent form - onInvalid: (...args) => this.onInvalid(...args), - onLoaded: () => { - this.nLoaded++; - this.checkAllLoaded(); - }, - createTable: (...args) => this.createTable(...args), - onOverride: (...args) => this.onOverride(...args), - base, - }); - } + const enableToggle = document.createElement("input"); + const enableToggleContainer = document.createElement("div"); + Object.assign(enableToggleContainer.style, { + position: "relative", + }); + enableToggleContainer.append(enableToggle); + + // Check properties that will be rendered before creating the accordion + const base = [...this.base, ...localPath]; + + const explicitlyRequired = schema.required?.includes(name) ?? false; + + Object.assign(enableToggle, { + type: "checkbox", + checked: true, + style: "margin-right: 10px; pointer-events:all;", + }); + + const headerName = header(name); + + const renderableInside = this.#getRenderable(info, required[name], localPath, true); + + const __disabled = this.results.__disabled ?? (this.results.__disabled = {}); + const __interacted = __disabled.__interacted ?? (__disabled.__interacted = {}); + + const hasInteraction = __interacted[name]; // NOTE: This locks the specific value to what the user has chosen... - if (!this.states[headerName]) this.states[headerName] = {}; - this.states[headerName].subtitle = `${ - this.#getRenderable(info, required[name], localPath, true).length - } fields`; - this.states[headerName].content = this.#nestedForms[name]; + const { __disabled: __tempDisabledGlobal = {} } = this.getGlobalValue(localPath.slice(0, -1)); - const accordion = new Accordion({ - sections: { - [headerName]: this.states[headerName], + const __disabledGlobal = structuredClone(__tempDisabledGlobal); // NOTE: Cloning ensures no property transfer + + let isGlobalEffect = !hasInteraction || (!hasInteraction && __disabledGlobal.__interacted?.[name]); // Indicate whether global effect is used + + const __disabledResolved = isGlobalEffect ? __disabledGlobal : __disabled; + + const isDisabled = !!__disabledResolved[name]; + + enableToggle.checked = !isDisabled; + + const nestedResults = __disabled[name] ?? results[name] ?? this.results[name]; // One or the other will exist—depending on global or local disabling + + if (renderableInside.length) { + this.#nestedForms[name] = new JSONSchemaForm({ + identifier: this.identifier, + schema: info, + results: { ...nestedResults }, + globals: this.globals?.[name], + + onUpdate: (internalPath, value, forceUpdate) => { + const path = [...localPath, ...internalPath]; + this.updateData(path, value, forceUpdate); }, + + required: required[name], // Scoped to the sub-schema + ignore: this.ignore, + dialogOptions: this.dialogOptions, + dialogType: this.dialogType, + onlyRequired: this.onlyRequired, + showLevelOverride: this.showLevelOverride, + deferLoading: this.deferLoading, + conditionalRequirements: this.conditionalRequirements, + validateOnChange: (...args) => this.validateOnChange(...args), + onThrow: (...args) => this.onThrow(...args), + validateEmptyValues: this.validateEmptyValues, + onStatusChange: (status) => { + accordion.setStatus(status); + this.checkStatus(); + }, // Forward status changes to the parent form + onInvalid: (...args) => this.onInvalid(...args), + onLoaded: () => { + this.nLoaded++; + this.checkAllLoaded(); + }, + createTable: (...args) => this.createTable(...args), + onOverride: (...args) => this.onOverride(...args), + base, + }); + } + + const oldStates = this.#accordions[headerName]; + + const accordion = (this.#accordions[headerName] = new Accordion({ + name: headerName, + toggleable: hasMany, + subtitle: html`
+ ${explicitlyRequired ? "" : enableToggleContainer}${renderableInside.length + ? `${renderableInside.length} fields` + : ""} +
`, + content: this.#nestedForms[name], + + // States + open: oldStates?.open ?? !hasMany, + disabled: isDisabled, + status: oldStates?.status ?? "valid", // Always show a status + })); + + accordion.id = name; // assign name to accordion id + + // Set enable / disable behavior + const addDisabled = (name, o) => { + if (!o.__disabled) o.__disabled = {}; + + // Do not overwrite cache of disabled values (with globals, for instance) + if (o.__disabled[name]) { + if (isGlobalEffect) return; + } + + o.__disabled[name] = o[name] ?? (o[name] = {}); // Track disabled values (or at least something) + }; + + const disable = () => { + accordion.disabled = true; + addDisabled(name, this.resolved); + addDisabled(name, this.results); + this.resolved[name] = this.results[name] = undefined; // Remove entry from results + + this.checkStatus(); + }; + + const enable = () => { + accordion.disabled = false; + + const { __disabled = {} } = this.results; + const { __disabled: resolvedDisabled = {} } = this.resolved; + + if (__disabled[name]) this.updateData(localPath, __disabled[name]); // Propagate restored disabled values + __disabled[name] = undefined; // Clear disabled value + resolvedDisabled[name] = undefined; // Clear disabled value + + this.checkStatus(); + }; + + enableToggle.addEventListener("click", (e) => { + e.stopPropagation(); + const { checked } = e.target; + + // Reset parameters on interaction + isGlobalEffect = false; + Object.assign(enableToggle.style, { + accentColor: "unset", }); - accordion.id = name; // assign name to accordion id + const { __disabled = {} } = this.results; + const { __disabled: resolvedDisabled = {} } = this.resolved; + + if (!__disabled.__interacted) __disabled.__interacted = {}; + if (!resolvedDisabled.__interacted) resolvedDisabled.__interacted = {}; + + __disabled.__interacted[name] = resolvedDisabled.__interacted[name] = true; // Track that the user has interacted with the form - if (!renderable.length) accordion.setAttribute("disabled", ""); + checked ? enable() : disable(); - return accordion; + this.onUpdate(localPath, this.results[name]); + }); + + if (isGlobalEffect) { + isDisabled ? disable() : enable(); + Object.assign(enableToggle.style, { + accentColor: "gray", + }); } - // Render properties in the sub-schema - const rendered = this.#render(info, results?.[name], required[name], localPath); - return hasMany || path.length > 1 - ? html` -
- -
- ${rendered} -
- ` - : rendered; + return accordion; }); return rendered; diff --git a/src/renderer/src/stories/JSONSchemaForm.stories.js b/src/renderer/src/stories/JSONSchemaForm.stories.js index a59857ef8..63e71610b 100644 --- a/src/renderer/src/stories/JSONSchemaForm.stories.js +++ b/src/renderer/src/stories/JSONSchemaForm.stories.js @@ -3,14 +3,7 @@ import { JSONSchemaForm } from "./JSONSchemaForm"; export default { title: "Components/JSON Schema Form", // Set controls - argTypes: { - mode: { - options: ["default", "accordion"], - control: { - type: "select", - }, - }, - }, + argTypes: {}, }; const Template = (args) => new JSONSchemaForm(args); @@ -53,7 +46,6 @@ Default.args = { export const Nested = Template.bind({}); Nested.args = { - mode: "accordion", results: { name: "name", ignored: true, diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 784db5813..4ad9a8742 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -135,7 +135,7 @@ export class JSONSchemaInput extends LitElement { getElement = () => this.shadowRoot.querySelector(".schema-input"); - #activateTimeoutValidation = (name, el, path) => { + #activateTimeoutValidation = (name, path) => { this.#clearTimeoutValidation(); this.#validationTimeout = setTimeout(() => { this.onValidate ? this.onValidate() : this.form ? this.form.triggerValidation(name, path) : ""; @@ -147,15 +147,15 @@ export class JSONSchemaInput extends LitElement { }; #validationTimeout = null; - #updateData = (fullPath, value) => { - this.onUpdate ? this.onUpdate(value) : this.form ? this.form.updateData(fullPath, value) : ""; + #updateData = (fullPath, value, forceUpdate) => { + this.onUpdate ? this.onUpdate(value) : this.form ? this.form.updateData(fullPath, value, forceUpdate) : ""; const path = [...fullPath]; const name = path.splice(-1)[0]; this.value = value; // Update the latest value - this.#activateTimeoutValidation(name, this.getElement(), path); + this.#activateTimeoutValidation(name, path); }; #triggerValidation = (name, path) => { @@ -227,6 +227,8 @@ export class JSONSchemaInput extends LitElement { schema: itemSchema, data: this.value, + onUpdate: () => this.#updateData(fullPath, tableMetadata.data, true), // Ensure change propagates to all forms + // NOTE: This is likely an incorrect declaration of the table validation call validateOnChange: (key, parent, v) => { return ( diff --git a/src/renderer/src/stories/SimpleTable.js b/src/renderer/src/stories/SimpleTable.js index 4835f3630..8d992d7e3 100644 --- a/src/renderer/src/stories/SimpleTable.js +++ b/src/renderer/src/stories/SimpleTable.js @@ -635,7 +635,7 @@ export class SimpleTable extends LitElement { else target[rowName][header] = value; } - if (cell.interacted) this.onUpdate(rowName, header, value); + if (cell.interacted) this.onUpdate([rowName, header], value); }; #createCell = (value, info) => { diff --git a/src/renderer/src/stories/forms/GlobalFormModal.ts b/src/renderer/src/stories/forms/GlobalFormModal.ts index 391785c52..f6de21e8b 100644 --- a/src/renderer/src/stories/forms/GlobalFormModal.ts +++ b/src/renderer/src/stories/forms/GlobalFormModal.ts @@ -46,7 +46,6 @@ export function createGlobalFormModal(this: Page, { const globalForm = new JSONSchemaForm({ validateEmptyValues: false, - mode: 'accordion', schema: schemaCopy, emptyMessage: "No properties to edit globally.", ignore: propsToIgnore, @@ -71,16 +70,20 @@ export function createGlobalFormModal(this: Page, { const forms = (hasInstances ? this.forms : this.form ? [ { form: this.form }] : []) ?? [] const tables = (hasInstances ? this.tables : this.table ? [ this.table ] : []) ?? [] + const mergeOpts = { + // remove: false + } + forms.forEach(formInfo => { const { subject, form } = formInfo - const result = cached[subject] ?? (cached[subject] = mergeFunction.call(formInfo, toPass, this.info.globalState)) + const result = cached[subject] ?? (cached[subject] = mergeFunction.call(formInfo, toPass, this.info.globalState, mergeOpts)) form.globals = structuredClone(key ? result.project[key]: result) }) tables.forEach(table => { const subject = null - const result = cached[subject] ?? (cached[subject] = mergeFunction(toPass, this.info.globalState)) + const result = cached[subject] ?? (cached[subject] = mergeFunction(toPass, this.info.globalState, mergeOpts)) table.globals = structuredClone( key ? result.project[key]: result) }) diff --git a/src/renderer/src/stories/pages/FormPage.js b/src/renderer/src/stories/pages/FormPage.js index ecf9ff88d..650a5f2e2 100644 --- a/src/renderer/src/stories/pages/FormPage.js +++ b/src/renderer/src/stories/pages/FormPage.js @@ -25,7 +25,10 @@ export function schemaToPages(schema, globalStatePath, options, transformationCa globalStatePath, formOptions: { ...optionsCopy, - schema: { properties: { [key]: value } }, + schema: { + properties: { [key]: value }, + required: [key], + }, }, }) ); diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js index f8f119616..11ed77495 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -156,10 +156,11 @@ export class GuidedMetadataPage extends ManagedPage { resolveResults(subject, session, globalState); + console.log(subject, session, results); + // Create the form const form = new JSONSchemaForm({ identifier: instanceId, - mode: "accordion", schema: preprocessMetadataSchema(schema), results, globals: aggregateGlobalMetadata, diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js index 20e804242..3b8fe1c1b 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js @@ -129,7 +129,7 @@ export class GuidedPathExpansionPage extends Page { const source_data = {}; for (let key in globalState.interfaces) { - const existing = existingSourceData?.[key] + const existing = existingSourceData?.[key]; if (existing) source_data[key] = existing ?? {}; } @@ -302,7 +302,7 @@ export class GuidedPathExpansionPage extends Page { if (fs) { const baseDir = form.getInput([...parentPath, "base_directory"]); if (name === "format_string_path") { - if (value && !baseDir.value) { + if (value && baseDir && !baseDir.value) { return [ { message: html`A base directory must be provided to locate your files.`, diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 80e0e3a8d..b725f134d 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -5,7 +5,7 @@ import { InstanceManager } from "../../../InstanceManager.js"; import { ManagedPage } from "./ManagedPage.js"; import { baseUrl } from "../../../../globals.js"; import { onThrow } from "../../../../errors"; -import { merge } from "../../utils.js"; +import { merge, sanitize } from "../../utils.js"; import preprocessSourceDataSchema from "../../../../../../../schemas/source-data.schema"; import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; @@ -13,6 +13,7 @@ import { header } from "../../../forms/utils"; import { Button } from "../../../Button.js"; import globalIcon from "../../../assets/global.svg?raw"; +import { run } from "../options/utils.js"; const propsToIgnore = [ "verbose", @@ -68,7 +69,7 @@ export class GuidedSourceDataPage extends ManagedPage { backdrop: "rgba(0,0,0, 0.4)", timerProgressBar: false, didOpen: () => { - Swal.showLoading(); + Swal.showLoading(); }, }); }; @@ -79,7 +80,6 @@ export class GuidedSourceDataPage extends ManagedPage { await Promise.all( Object.values(this.forms).map(async ({ subject, session, form }) => { - const info = this.info.globalState.results[subject][session]; // NOTE: This clears all user-defined results @@ -87,7 +87,7 @@ export class GuidedSourceDataPage extends ManagedPage { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - source_data: form.resolved, // Use resolved values, including global source data + source_data: sanitize(structuredClone(form.resolved)), // Use resolved values, including global source data interfaces: this.info.globalState.interfaces, }), }) @@ -117,18 +117,30 @@ export class GuidedSourceDataPage extends ManagedPage { throw result; } + const { results: metadata, schema } = result; + + // Always delete Ecephys if absent ( NOTE: temporarily manually removing from schema on backend...) + const alwaysDelete = ["Ecephys"]; + alwaysDelete.forEach((k) => { + if (!metadata[k]) delete info.metadata[k]; // Delete directly on metadata + }); + + for (let key in info.metadata) { + if (!alwaysDelete.includes(key) && !(key in schema.properties)) metadata[key] = undefined; + } + // Merge metadata results with the generated info - merge(result.results, info.metadata); + merge(metadata, info.metadata); // Mirror structure with metadata schema - const schema = this.info.globalState.schema; - if (!schema.metadata) schema.metadata = {}; - if (!schema.metadata[subject]) schema.metadata[subject] = {}; - schema.metadata[subject][session] = result.schema; + const schemaGlobal = this.info.globalState.schema; + if (!schemaGlobal.metadata) schemaGlobal.metadata = {}; + if (!schemaGlobal.metadata[subject]) schemaGlobal.metadata[subject] = {}; + schemaGlobal.metadata[subject][session] = schema; }) ); - await this.save(); + await this.save(undefined, false); // Just save new raw values this.to(1); }, @@ -142,7 +154,6 @@ export class GuidedSourceDataPage extends ManagedPage { const form = new JSONSchemaForm({ identifier: instanceId, - mode: "accordion", schema: preprocessSourceDataSchema(schema), results: info.source_data, emptyMessage: "No source data required for this session.", @@ -152,7 +163,9 @@ export class GuidedSourceDataPage extends ManagedPage { this.notify(`${header(name)} has been overriden with a global value.`, "warning", 3000); }, // onlyRequired: true, - onUpdate: () => (this.unsavedUpdates = true), + onUpdate: () => { + this.unsavedUpdates = true; + }, onStatusChange: (state) => this.manager.updateState(instanceId, state), onThrow, }); @@ -173,8 +186,8 @@ export class GuidedSourceDataPage extends ManagedPage { const modal = (this.#globalModal = createGlobalFormModal.call(this, { header: "Global Source Data", propsToRemove: [ - ...propsToIgnore, - "folder_path", + ...propsToIgnore, + "folder_path", "file_path", // NOTE: Still keeping plural path specifications for now ], diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js index c815215dc..8c92d5428 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js @@ -87,10 +87,10 @@ export class GuidedStructurePage extends Page { this.mapSessions(({ info }) => { Object.keys(info.source_data).forEach((key) => { if (!this.info.globalState.interfaces[key]) delete info.source_data[key]; - }) - }) + }); + }); } - + await this.save(undefined, false); // Interrim save, in case the schema request fails await this.getSchema(); }; diff --git a/src/renderer/src/stories/pages/guided-mode/data/utils.js b/src/renderer/src/stories/pages/guided-mode/data/utils.js index 18deee495..844a4224a 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/utils.js +++ b/src/renderer/src/stories/pages/guided-mode/data/utils.js @@ -2,7 +2,7 @@ import { merge } from "../../utils.js"; // Merge project-wide data into metadata export function populateWithProjectMetadata(info, globalState) { - const copy = structuredClone(info) + const copy = structuredClone(info); const toMerge = Object.entries(globalState.project).filter(([_, value]) => value && typeof value === "object"); toMerge.forEach(([key, value]) => { let internalMetadata = copy[key]; @@ -48,7 +48,7 @@ export function resolveProperties(properties = {}, target, globals = {}) { else if (info.default) target[name] = info.default; } - resolveProperties(props, target[name], globals[name]); + if (target[name]) resolveProperties(props, target[name], globals[name]); } return target; diff --git a/src/renderer/src/stories/pages/guided-mode/options/GuidedInspectorPage.js b/src/renderer/src/stories/pages/guided-mode/options/GuidedInspectorPage.js index 5a9efaae8..db1623de9 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/GuidedInspectorPage.js +++ b/src/renderer/src/stories/pages/guided-mode/options/GuidedInspectorPage.js @@ -37,9 +37,9 @@ export class GuidedInspectorPage extends Page { super(...args); this.style.height = "100%"; // Fix main section Object.assign(this.style, { - display: 'flex', - flexDirection: 'column' - }) + display: "flex", + flexDirection: "column", + }); } header = { @@ -85,85 +85,89 @@ export class GuidedInspectorPage extends Page { ) .flat(); return html` ${new InfoBox({ - header: "How do I fix these suggestions?", - content: html`We suggest editing the Global Metadata on the previous page to fix any issues shared - across files.`, - })} - -
- - ${until( - (async () => { - if (fileArr.length <= 1) { - const items = - inspector ?? - removeFilePaths( + header: "How do I fix these suggestions?", + content: html`We suggest editing the Global Metadata on the previous page to fix any issues + shared across files.`, + })} + +
+ + ${until( + (async () => { + if (fileArr.length <= 1) { + const items = + inspector ?? + removeFilePaths( + (this.unsavedUpdates = globalState.preview.inspector = + await run( + "inspect_file", + { nwbfile_path: fileArr[0].info.file, ...opts }, + { title } + )) + ); + return new InspectorList({ items, emptyMessage }); + } + + const items = await (async () => { + const path = getSharedPath(fileArr.map((o) => o.info.file)); + const report = + inspector ?? (this.unsavedUpdates = globalState.preview.inspector = - await run("inspect_file", { nwbfile_path: fileArr[0].info.file, ...opts }, { title })) - ); - return new InspectorList({ items, emptyMessage }); - } - - const items = await (async () => { - const path = getSharedPath(fileArr.map((o) => o.info.file)); - const report = - inspector ?? - (this.unsavedUpdates = globalState.preview.inspector = - await run("inspect_folder", { path, ...opts }, { title: title + "s" })); - return truncateFilePaths(report, path); - })(); - - const _instances = fileArr.map(({ subject, session, info }) => { - const file_path = [`sub-${subject}`, `sub-${subject}_ses-${session}`]; - const filtered = removeFilePaths(filter(items, { file_path })); - - const display = () => new InspectorList({ items: filtered, emptyMessage }); - display.status = this.getStatus(filtered); - - return { - subject, - session, - display, + await run("inspect_folder", { path, ...opts }, { title: title + "s" })); + return truncateFilePaths(report, path); + })(); + + const _instances = fileArr.map(({ subject, session, info }) => { + const file_path = [`sub-${subject}`, `sub-${subject}_ses-${session}`]; + const filtered = removeFilePaths(filter(items, { file_path })); + + const display = () => new InspectorList({ items: filtered, emptyMessage }); + display.status = this.getStatus(filtered); + + return { + subject, + session, + display, + }; + }); + + const instances = _instances.reduce((acc, { subject, session, display }) => { + const subLabel = `sub-${subject}`; + if (!acc[`sub-${subject}`]) acc[subLabel] = {}; + acc[subLabel][`ses-${session}`] = display; + return acc; + }, {}); + + Object.keys(instances).forEach((subLabel) => { + const subItems = filter(items, { file_path: `${subLabel}${nodePath.sep}${subLabel}_ses-` }); // NOTE: This will not run on web-only now + const path = getSharedPath(subItems.map((o) => o.file_path)); + const filtered = truncateFilePaths(subItems, path); + + const display = () => new InspectorList({ items: filtered, emptyMessage }); + display.status = this.getStatus(filtered); + + instances[subLabel] = { + ["All Files"]: display, + ...instances[subLabel], + }; + }); + + const allDisplay = () => new InspectorList({ items, emptyMessage }); + allDisplay.status = this.getStatus(items); + + const allInstances = { + ["All Files"]: allDisplay, + ...instances, }; - }); - - const instances = _instances.reduce((acc, { subject, session, display }) => { - const subLabel = `sub-${subject}`; - if (!acc[`sub-${subject}`]) acc[subLabel] = {}; - acc[subLabel][`ses-${session}`] = display; - return acc; - }, {}); - - Object.keys(instances).forEach((subLabel) => { - const subItems = filter(items, { file_path: `${subLabel}${nodePath.sep}${subLabel}_ses-` }); // NOTE: This will not run on web-only now - const path = getSharedPath(subItems.map((o) => o.file_path)); - const filtered = truncateFilePaths(subItems, path); - - const display = () => new InspectorList({ items: filtered, emptyMessage }); - display.status = this.getStatus(filtered); - - instances[subLabel] = { - ["All Files"]: display, - ...instances[subLabel], - }; - }); - - const allDisplay = () => new InspectorList({ items, emptyMessage }); - allDisplay.status = this.getStatus(items); - - const allInstances = { - ["All Files"]: allDisplay, - ...instances, - }; - const manager = new InstanceManager({ - instances: allInstances, - }); + const manager = new InstanceManager({ + instances: allInstances, + }); - return manager; - })(), - "" - )}`; + return manager; + })(), + "" + )}`; } } diff --git a/src/renderer/src/stories/pages/guided-mode/options/utils.js b/src/renderer/src/stories/pages/guided-mode/options/utils.js index 48031dd06..3eb939744 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/utils.js +++ b/src/renderer/src/stories/pages/guided-mode/options/utils.js @@ -1,5 +1,6 @@ import Swal from "sweetalert2"; import { baseUrl } from "../../../../globals.js"; +import { sanitize } from "../../utils.js"; export const openProgressSwal = (options) => { return new Promise((resolve) => { @@ -26,6 +27,9 @@ export const run = async (url, payload, options = {}) => { if (!("base" in options)) options.base = "/neuroconv"; + // Clear private keys from being passed + payload = sanitize(structuredClone(payload)); + const results = await fetch(`${baseUrl}${options.base || ""}/${url}`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index 0b3b47395..749f6368a 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -12,6 +12,7 @@ const schema = { output_locations: projectGlobalSchema, DANDI: dandiGlobalSchema, }, + required: ["output_locations", "DANDI"], }; import { Button } from "../../Button.js"; @@ -61,14 +62,12 @@ export class SettingsPage extends Page { }; render() { - this.localState = structuredClone(global.data); // NOTE: API Keys and Dandiset IDs persist across selected project this.form = new JSONSchemaForm({ results: this.localState, schema, - mode: "accordion", onUpdate: () => (this.unsavedUpdates = true), validateOnChange: async (name, parent) => { const value = parent[name]; diff --git a/src/renderer/src/stories/pages/uploads/UploadsPage.js b/src/renderer/src/stories/pages/uploads/UploadsPage.js index 03b4e2a9e..0df8220a9 100644 --- a/src/renderer/src/stories/pages/uploads/UploadsPage.js +++ b/src/renderer/src/stories/pages/uploads/UploadsPage.js @@ -51,7 +51,7 @@ export async function uploadToDandi(info, type = "project" in info ? "project" : const staging = isStaging(dandiset_id); // Automatically detect staging IDs const whichAPIKey = staging ? "staging_api_key" : "main_api_key"; - const DANDI = global.data.DANDI + const DANDI = global.data.DANDI; let api_key = DANDI?.api_keys?.[whichAPIKey]; const errors = await validateDANDIApiKey(api_key, staging); @@ -66,7 +66,7 @@ export async function uploadToDandi(info, type = "project" in info ? "project" : const input = new JSONSchemaInput({ path: [whichAPIKey], - info: dandiGlobalSchema.properties.api_keys.properties[whichAPIKey] + info: dandiGlobalSchema.properties.api_keys.properties[whichAPIKey], }); input.style.padding = "25px"; @@ -92,14 +92,17 @@ export async function uploadToDandi(info, type = "project" in info ? "project" : const errors = await validateDANDIApiKey(input.value, staging); if (!errors || !errors.length) { modal.remove(); - - merge({ - DANDI: { - api_keys: { - [whichAPIKey]: value - } - } - }, global.data) + + merge( + { + DANDI: { + api_keys: { + [whichAPIKey]: value, + }, + }, + }, + global.data + ); global.save(); resolve(value); @@ -118,7 +121,7 @@ export async function uploadToDandi(info, type = "project" in info ? "project" : document.body.append(modal); }); - console.log(api_key) + console.log(api_key); } const result = await run( diff --git a/src/renderer/src/stories/pages/utils.js b/src/renderer/src/stories/pages/utils.js index 59beb7be4..fd1d65e6e 100644 --- a/src/renderer/src/stories/pages/utils.js +++ b/src/renderer/src/stories/pages/utils.js @@ -27,12 +27,26 @@ export const setUndefinedIfNotDeclared = (schemaProps, resolved) => { } }; +export const isPrivate = (k, v) => k.slice(0, 2) === "__"; + +export const sanitize = (o, condition = isPrivate) => { + if (isObject(o)) { + for (const [k, v] of Object.entries(o)) { + if (condition(k, v)) delete o[k]; + else sanitize(v, condition); + } + } + + return o; +}; + export function merge(toMerge = {}, target = {}, mergeOpts = {}) { // Deep merge objects for (const [k, v] of Object.entries(toMerge)) { const targetV = target[k]; // if (isPrivate(k)) continue; - if (mergeOpts.arrays && Array.isArray(v) && Array.isArray(targetV)) target[k] = [...targetV, ...v]; // Merge array entries together + if (mergeOpts.arrays && Array.isArray(v) && Array.isArray(targetV)) + target[k] = [...targetV, ...v]; // Merge array entries together else if (v === undefined) { delete target[k]; // Remove matched values // if (mergeOpts.remove !== false) delete target[k]; // Remove matched values @@ -51,7 +65,7 @@ export function merge(toMerge = {}, target = {}, mergeOpts = {}) { export function mapSessions(callback = (v) => v, globalState) { return Object.entries(globalState.results) .map(([subject, sessions]) => { - return Object.entries(sessions).map(([session, info]) => callback({ subject, session, info })); + return Object.entries(sessions).map(([session, info], i) => callback({ subject, session, info }, i)); }) .flat(2); } diff --git a/src/renderer/src/stories/table/Cell.ts b/src/renderer/src/stories/table/Cell.ts index e258e218f..2a25d577e 100644 --- a/src/renderer/src/stories/table/Cell.ts +++ b/src/renderer/src/stories/table/Cell.ts @@ -20,6 +20,8 @@ type TableCellProps = { onValidate?: OnValidateFunction, } +const persistentInteraction = Symbol('persistentInteraction') + export class TableCell extends LitElement { declare schema: TableCellProps['schema'] @@ -132,8 +134,9 @@ export class TableCell extends LitElement { }; setInput(value: any) { - this.interacted = true - this.input.set(value) // Ensure all operations are undoable + this.interacted = persistentInteraction + if (this.input) this.input.set(value) // Ensure all operations are undoable + else this.#value = value // Silently set value if not rendered yet } #value @@ -147,7 +150,7 @@ export class TableCell extends LitElement { #cls: any - interacted = false + interacted: boolean | symbol = false // input = new TableCellBase({ }) @@ -157,7 +160,7 @@ export class TableCell extends LitElement { let cls = TableCellBase - this.interacted = false + this.interacted = this.interacted === persistentInteraction if (this.schema.type === "array") cls = ArrayCell else if (this.schema.format === "date-time") cls = DateTimeCell diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 2b53a2fb2..b5dab4e9e 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -113,8 +113,7 @@ test('inter-table updates are triggered', async () => { await form.rendered // Validate that the results are incorrect - let errors = false - await form.validate().catch(e => errors = true) + const errors = await form.validate().catch(() => true).catch(e => true) expect(errors).toBe(true) // Is invalid // Update the table with the missing electrode group @@ -123,13 +122,19 @@ test('inter-table updates are triggered', async () => { const baseRow = table.getRow(0) row.forEach((cell, i) => { - if (cell.simpleTableInfo.col === 'name') cell.value = randomStringId // Set name to random string id - else cell.value = baseRow[i].value // Otherwise carry over info + if (cell.simpleTableInfo.col === 'name') cell.setInput(randomStringId) // Set name to random string id + else cell.setInput(baseRow[i].value) // Otherwise carry over info }) + // Wait a second for new row values to resolve as table data (async) + await new Promise((res) => setTimeout(() => res(true), 1000)) + // Validate that the new structure is correct - await form.validate().then(res => errors = false).catch(e => errors = true) - expect(errors).toBe(false) // Is valid + const hasErrors = await form.validate().then(() => false).catch((e) => { + console.error(e) + return true + }) + expect(hasErrors).toBe(false) // Is valid }) @@ -193,6 +198,7 @@ test('changes are resolved correctly', async () => { input3.updateData('test') // Validate that the new structure is correct - await form.validate(form.results).then(res => errors = false).catch(e => errors = true) - expect(errors).toBe(false) // Is valid + const hasErrors = await form.validate(form.results).then(res => false).catch(e => true) + + expect(hasErrors).toBe(false) // Is valid })