diff --git a/schemas/base-metadata.schema.ts b/schemas/base-metadata.schema.ts index 08063e099..87d237af9 100644 --- a/schemas/base-metadata.schema.ts +++ b/schemas/base-metadata.schema.ts @@ -11,4 +11,31 @@ export const preprocessMetadataSchema = (schema: any = baseMetadataSchema) => { } + +export const instanceSpecificFields = { + Subject: ["weight", "subject_id", "age", "date_of_birth", "age__reference"], + NWBFile: [ + "session_id", + "session_start_time", + "identifier", + "data_collection", + "notes", + "pharmacolocy", + "session_description", + "slices", + "source_script", + "source_script_file_name", + ], +}; + + +const globalSchema = structuredClone(preprocessMetadataSchema()); +Object.entries(globalSchema.properties).forEach(([globalProp, schema]) => { + instanceSpecificFields[globalProp]?.forEach((prop) => delete schema.properties[prop]); +}); + +export { + globalSchema +} + export default preprocessMetadataSchema() diff --git a/schemas/json/base_metadata_schema.json b/schemas/json/base_metadata_schema.json index 4b7503e81..00f4d4d37 100644 --- a/schemas/json/base_metadata_schema.json +++ b/schemas/json/base_metadata_schema.json @@ -1,8 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "$id": "base_metafile.schema.json", - "title": "Base schema for the metafile", - "description": "Base schema for the metafile", "version": "0.1.0", "type": "object", "required": ["NWBFile"], diff --git a/src/renderer/src/stories/BasicTable.js b/src/renderer/src/stories/BasicTable.js index 1cad4503d..5d3396c32 100644 --- a/src/renderer/src/stories/BasicTable.js +++ b/src/renderer/src/stories/BasicTable.js @@ -154,7 +154,7 @@ export class BasicTable extends LitElement { } else value = (hasRow ? this.data[row][col] : undefined) ?? - // this.template[col] ?? + // this.globals[col] ?? this.schema.properties[col].default ?? ""; return value; diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index c17ae04ac..1e80b594a 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -117,10 +117,12 @@ hr { .required label:after { content: " *"; color: #ff0033; + } - :host([requirementmode="loose"]) .required label:after { + :host(:not([validateemptyvalues])) .required label:after { color: gray; + } @@ -163,7 +165,8 @@ export class JSONSchemaForm extends LitElement { required: { type: Object, reflect: false }, dialogType: { type: String, reflect: false }, dialogOptions: { type: Object, reflect: false }, - requirementMode: { type: String, reflect: true }, + globals: { type: Object, reflect: false }, + validateEmptyValues: { type: Boolean, reflect: true }, }; } @@ -184,6 +187,8 @@ export class JSONSchemaForm extends LitElement { resolved = {}; // Keep track of actual resolved values—not just what the user provides as results + states = {}; + constructor(props = {}) { super(); @@ -195,6 +200,8 @@ export class JSONSchemaForm extends LitElement { 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; @@ -203,7 +210,6 @@ export class JSONSchemaForm extends LitElement { this.emptyMessage = props.emptyMessage ?? "No properties to render"; - this.requirementMode = props.requirementMode ?? "default"; this.onlyRequired = props.onlyRequired ?? false; this.showLevelOverride = props.showLevelOverride ?? false; @@ -219,6 +225,7 @@ export class JSONSchemaForm extends LitElement { if (props.onLoaded) this.onLoaded = props.onLoaded; if (props.onUpdate) this.onUpdate = props.onUpdate; if (props.renderTable) this.renderTable = props.renderTable; + if (props.onOverride) this.onOverride = props.onOverride; if (props.onStatusChange) this.onStatusChange = props.onStatusChange; @@ -258,6 +265,12 @@ export class JSONSchemaForm extends LitElement { if (changedProperties === "options") this.requestUpdate(); } + getGlobalValue(path) { + if (typeof path === "string") path = path.split("."); + const resolved = this.#get(path, this.globals); + return resolved; + } + // Track resolved values for the form (data only) updateData(localPath, value) { const path = [...localPath]; @@ -269,12 +282,25 @@ export class JSONSchemaForm extends LitElement { const resolvedParent = path.reduce(reducer, this.resolved); const hasUpdate = resolvedParent[name] !== value; + const globalValue = this.getGlobalValue(localPath); + // NOTE: Forms with nested forms will handle their own state updates - if (!value) { - delete resultParent[name]; - delete resolvedParent[name]; + if (this.isUndefined(value)) { + const globalValue = this.getGlobalValue(localPath); + + // Continue to resolve and re-render... + if (globalValue) { + value = resolvedParent[name] = globalValue; + const input = this.getInput(localPath); + if (input) { + input.updateData(globalValue); + this.onOverride(name, globalValue, path); + } + } else resolvedParent[name] = undefined; + + resultParent[name] = undefined; // NOTE: Will be removed when stringified } else { - resultParent[name] = value; + resultParent[name] = value === globalValue ? undefined : value; // Retain association with global value resolvedParent[name] = value; } @@ -317,7 +343,7 @@ export class JSONSchemaForm extends LitElement { validate = async (resolved) => { // Check if any required inputs are missing const requiredButNotSpecified = await this.#validateRequirements(resolved); // get missing required paths - const isValid = this.requirementMode === "loose" ? true : !requiredButNotSpecified.length; + const isValid = !requiredButNotSpecified.length; // Print out a detailed error message if any inputs are missing let message = isValid ? "" : `${requiredButNotSpecified.length} required inputs are not specified properly.`; @@ -366,7 +392,10 @@ export class JSONSchemaForm extends LitElement { #get = (path, object = this.resolved, omitted = []) => { // path = path.slice(this.base.length); // Correct for base path - return path.reduce((acc, curr) => (acc = acc[curr] ?? acc?.[omitted.find((str) => acc[str])]?.[curr]), object); + return path.reduce( + (acc, curr) => (acc = acc?.[curr] ?? acc?.[omitted.find((str) => acc[str])]?.[curr]), + object + ); }; #checkRequiredAfterChange = async (localPath) => { @@ -482,7 +511,7 @@ export class JSONSchemaForm extends LitElement { if (typeof isRequired === "object" && !Array.isArray(isRequired)) invalid.push(...(await this.#validateRequirements(resolved[name], isRequired, path))); - else if (!resolved[name]) invalid.push(path); + else if (this.isUndefined(resolved[name]) && this.validateEmptyValues) invalid.push(path); } } @@ -493,14 +522,15 @@ export class JSONSchemaForm extends LitElement { onInvalid = () => {}; onLoaded = () => {}; onUpdate = () => {}; + onOverride = () => {}; - #deleteExtraneousResults = (results, schema) => { - for (let name in results) { - if (!schema.properties || !(name in schema.properties)) delete results[name]; - else if (results[name] && typeof results[name] === "object" && !Array.isArray(results[name])) - this.#deleteExtraneousResults(results[name], schema.properties[name]); - } - }; + // #deleteExtraneousResults = (results, schema) => { + // for (let name in results) { + // if (!schema.properties || !(name in schema.properties)) delete results[name]; + // else if (results[name] && typeof results[name] === "object" && !Array.isArray(results[name])) + // this.#deleteExtraneousResults(results[name], schema.properties[name]); + // } + // }; #getRenderable = (schema = {}, required, path, recursive = false) => { const entries = Object.entries(schema.properties ?? {}); @@ -594,6 +624,10 @@ export class JSONSchemaForm extends LitElement { return this.shadowRoot.querySelector(`[data-name="${link.name}"]`); }; + isUndefined(value) { + return value === undefined || value === ""; + } + // Assume this is going to return as a Promise—even if the change function isn't returning one triggerValidation = async (name, path = [], checkLinks = true) => { const parent = this.#get(path, this.resolved); @@ -601,7 +635,7 @@ export class JSONSchemaForm extends LitElement { const pathToValidate = [...(this.base ?? []), ...path]; const valid = - !this.validateEmptyValues && !(name in parent) + !this.validateEmptyValues && parent[name] === undefined ? true : await this.validateOnChange(name, parent, pathToValidate); @@ -609,6 +643,7 @@ export class JSONSchemaForm extends LitElement { const externalPath = [...this.base, name]; const isRequired = this.#isRequired(localPath); + let warnings = Array.isArray(valid) ? valid.filter((info) => info.type === "warning" && (!isRequired || !info.missing)) : []; @@ -635,15 +670,22 @@ export class JSONSchemaForm extends LitElement { } } else { // For non-links, throw a basic requirement error if the property is required - if (!errors.length && isRequired && !parent[name]) { - // Skip simple required checks in loose mode - if (this.requirementMode !== "loose") { - const schema = this.getSchema(localPath); + if (!errors.length && isRequired && this.isUndefined(parent[name])) { + const schema = this.getSchema(localPath); + + // Throw at least a basic warning if the property is required and missing + if (this.validateEmptyValues) { errors.push({ message: `${schema.title ?? header(name)} is a required property.`, type: "error", missing: true, - }); // Throw at least a basic error if the property is required + }); + } else { + warnings.push({ + message: `${schema.title ?? header(name)} is a suggested property.`, + type: "warning", + missing: true, + }); } } } @@ -805,6 +847,8 @@ export class JSONSchemaForm extends LitElement { results: { ...results[name] }, globals: this.globals?.[name], + states: this.states, + mode: this.mode, onUpdate: (internalPath, value) => { @@ -833,15 +877,19 @@ export class JSONSchemaForm extends LitElement { this.checkAllLoaded(); }, renderTable: (...args) => this.renderTable(...args), + onOverride: (...args) => this.onOverride(...args), base: [...this.base, ...localPath], }); + 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 accordion = new Accordion({ sections: { - [headerName]: { - subtitle: `${this.#getRenderable(info, required[name], localPath, true).length} fields`, - content: this.#nestedForms[name], - }, + [headerName]: this.states[headerName], }, }); diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index a74c20532..82cb73e7f 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -121,6 +121,12 @@ export class JSONSchemaInput extends LitElement { const el = this.getElement(); if (el.type === "checkbox") el.checked = value; + else if (el.classList.contains("list")) + el.children[0].items = value + ? value.map((value) => { + return { value }; + }) + : []; else el.value = value; return true; @@ -311,7 +317,7 @@ export class JSONSchemaInput extends LitElement { }); return html` -
validateOnChange && this.#triggerValidation(name, path)}> +
validateOnChange && this.#triggerValidation(name, path)}> ${list} ${addButton}
`; diff --git a/src/renderer/src/stories/SimpleTable.js b/src/renderer/src/stories/SimpleTable.js index 0f09c62b5..4835f3630 100644 --- a/src/renderer/src/stories/SimpleTable.js +++ b/src/renderer/src/stories/SimpleTable.js @@ -163,7 +163,7 @@ export class SimpleTable extends LitElement { constructor({ schema, data, - template, + globals, keyColumn, validateOnChange, validateEmptyCells, @@ -178,7 +178,7 @@ export class SimpleTable extends LitElement { this.schema = schema ?? {}; this.data = data ?? []; this.keyColumn = keyColumn; - this.template = template ?? {}; + this.globals = globals ?? {}; this.validateEmptyCells = validateEmptyCells ?? true; this.deferLoading = deferLoading ?? false; this.maxHeight = maxHeight ?? ""; @@ -334,7 +334,7 @@ export class SimpleTable extends LitElement { } else value = (hasRow ? this.data[row][col] : undefined) ?? - this.template[col] ?? + this.globals[col] ?? this.schema.properties[col].default ?? ""; return value; @@ -631,7 +631,7 @@ export class SimpleTable extends LitElement { } // Update data on passed object else { - if (value == undefined || value === "") delete target[rowName][header]; + if (value == undefined || value === "") target[rowName][header] = undefined; else target[rowName][header] = value; } diff --git a/src/renderer/src/stories/Table.js b/src/renderer/src/stories/Table.js index daf320ea9..9984a75bf 100644 --- a/src/renderer/src/stories/Table.js +++ b/src/renderer/src/stories/Table.js @@ -70,10 +70,11 @@ export class Table extends LitElement { constructor({ schema, data, - template, + globals, keyColumn, validateOnChange, onUpdate, + onOverride, validateEmptyCells, onStatusChange, contextMenu, @@ -82,11 +83,12 @@ export class Table extends LitElement { this.schema = schema ?? {}; this.data = data ?? []; this.keyColumn = keyColumn; - this.template = template ?? {}; + this.globals = globals ?? {}; this.validateEmptyCells = validateEmptyCells ?? true; this.contextMenu = contextMenu ?? {}; if (onUpdate) this.onUpdate = onUpdate; + if (onOverride) this.onOverride = onOverride; if (validateOnChange) this.validateOnChange = validateOnChange; if (onStatusChange) this.onStatusChange = onStatusChange; @@ -102,6 +104,7 @@ export class Table extends LitElement { static get properties() { return { data: { type: Object, reflect: true }, + globals: { type: Object, reflect: true }, }; } @@ -116,7 +119,7 @@ export class Table extends LitElement { if (col === this.keyColumn) { if (hasRow) value = row; else return undefined; - } else value = (hasRow ? this.data[row][col] : undefined) ?? this.template[col]; + } else value = (hasRow ? this.data[row][col] : undefined) ?? this.globals[col]; return value; }); @@ -138,7 +141,7 @@ export class Table extends LitElement { if (!message) { const errors = this.querySelectorAll("[error]"); const len = errors.length; - if (len === 1) message = errors[0].title || "Error found"; + if (len === 1) message = errors[0].getAttribute("data-message") || "Error found"; else if (len) message = `${len} errors exist on this table.`; } @@ -154,6 +157,7 @@ export class Table extends LitElement { status; onStatusChange = () => {}; onUpdate = () => {}; + onOverride = () => {}; isRequired = (col) => { return this.schema?.required?.includes(col); @@ -356,6 +360,8 @@ export class Table extends LitElement { descriptionEl.innerText = desc; } + if (this.table) this.table.destroy(); + const table = new Handsontable(div, { data, // rowHeaders: rowHeaders.map(v => `sub-${v}`), @@ -401,6 +407,8 @@ export class Table extends LitElement { } } + const isUserUpdate = initialCellsToUpdate <= validated; + // Transfer data to object if (header === this.keyColumn) { console.log(value, rowName); @@ -415,13 +423,21 @@ export class Table extends LitElement { // Update data on passed object else { - if (value == undefined || value === "") delete target[rowName][header]; - else target[rowName][header] = value; + const globalValue = this.globals[header]; + + if (value == undefined || value === "") { + if (globalValue) { + value = target[rowName][header] = globalValue; + table.setDataAtCell(row, prop, value); + this.onOverride(header, value, rowName); + } + target[rowName][header] = undefined; + } else target[rowName][header] = value === globalValue ? undefined : value; } validated++; - if (initialCellsToUpdate < validated) this.onUpdate(rowName, header, value); + if (isUserUpdate) this.onUpdate(rowName, header, value); if (typeof isValid === "function") isValid(); // } @@ -470,21 +486,27 @@ export class Table extends LitElement { const cell = this.table.getCell(row, prop); // NOTE: Does not resolve unless the cell is rendered... if (cell) { - let title = ""; + let message = ""; let theme = ""; if (warnings.length) { - (theme = "warning"), (title = warnings.map((o) => o.message).join("\n")); + (theme = "warning"), (message = warnings.map((o) => o.message).join("\n")); } else cell.removeAttribute("warning"); if (errors.length) { - (theme = "error"), (title = errors.map((o) => o.message).join("\n")); // Class switching handled automatically + (theme = "error"), (message = errors.map((o) => o.message).join("\n")); // Class switching handled automatically } else cell.removeAttribute("error"); if (theme) cell.setAttribute(theme, ""); - if (cell._tippy) cell._tippy.destroy(); + if (cell._tippy) { + cell._tippy.destroy(); + cell.removeAttribute("data-message"); + } - if (title) tippy(cell, { content: title, theme }); + if (message) { + tippy(cell, { content: message, theme }); + cell.setAttribute("data-message", message); + } } this.#checkStatus(); // Check status after every validation update diff --git a/src/renderer/src/stories/forms/GlobalFormModal.ts b/src/renderer/src/stories/forms/GlobalFormModal.ts new file mode 100644 index 000000000..391785c52 --- /dev/null +++ b/src/renderer/src/stories/forms/GlobalFormModal.ts @@ -0,0 +1,99 @@ +import { Modal } from "../Modal" +import { Page } from "../pages/Page.js" +import { Button } from "../Button" +import { JSONSchemaForm } from "../JSONSchemaForm" + +import { onThrow } from "../../errors"; +import { merge } from "../pages/utils.js"; +import { save } from "../../progress/index.js"; + + +export function createGlobalFormModal(this: Page, { + header, + schema, + propsToIgnore = [], + propsToRemove = [], + key, + hasInstances = false, + validateOnChange, + mergeFunction = merge +}: { + header: string + schema: any + propsToIgnore?: string[] + propsToRemove?: string[] + key?: string, + hasInstances?: boolean + validateOnChange?: Function, + mergeFunction?: Function + +}) { + + const modal = new Modal({ + header + }) + + const content = document.createElement("div") + + const schemaCopy = structuredClone(schema) // Ensure no mutation + + function removeProperties(obj: any, props: string[]) { + props.forEach(prop => delete obj[prop]) + } + + if (hasInstances) Object.keys(schemaCopy.properties).forEach(i => removeProperties(schemaCopy.properties[i].properties, propsToRemove)) + else removeProperties(schemaCopy.properties, propsToRemove) + + const globalForm = new JSONSchemaForm({ + validateEmptyValues: false, + mode: 'accordion', + schema: schemaCopy, + emptyMessage: "No properties to edit globally.", + ignore: propsToIgnore, + onThrow, + validateOnChange + }) + + content.append(globalForm) + + content.style.padding = "25px" + + const saveButton = new Button({ + label: "Update", + primary: true, + onClick: async () => { + await globalForm.validate() + + const cached: any = {} + + const toPass = { project: key ? {[key]: globalForm.results} : globalForm.results} + + const forms = (hasInstances ? this.forms : this.form ? [ { form: this.form }] : []) ?? [] + const tables = (hasInstances ? this.tables : this.table ? [ this.table ] : []) ?? [] + + forms.forEach(formInfo => { + const { subject, form } = formInfo + const result = cached[subject] ?? (cached[subject] = mergeFunction.call(formInfo, toPass, this.info.globalState)) + 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)) + table.globals = structuredClone( key ? result.project[key]: result) + }) + + await save(this) // Save after all updates are made + + modal.open = false + } + }) + + modal.form = globalForm + + modal.footer = saveButton + + modal.append(content) + return modal +} diff --git a/src/renderer/src/stories/pages/FormPage.js b/src/renderer/src/stories/pages/FormPage.js index de82cceab..ad088732c 100644 --- a/src/renderer/src/stories/pages/FormPage.js +++ b/src/renderer/src/stories/pages/FormPage.js @@ -30,8 +30,6 @@ export function schemaToPages(schema, globalStatePath, options, transformationCa }) ); - delete schema.properties[key]; - if (optionsCopy.ignore && optionsCopy.ignore.includes(key)) return null; return page; }) diff --git a/src/renderer/src/stories/pages/Page.js b/src/renderer/src/stories/pages/Page.js index 2deb6e1c4..7b1e90da6 100644 --- a/src/renderer/src/stories/pages/Page.js +++ b/src/renderer/src/stories/pages/Page.js @@ -2,7 +2,7 @@ import { LitElement, html } from "lit"; import { openProgressSwal, runConversion } from "./guided-mode/options/utils.js"; import { get, save } from "../../progress/index.js"; import { dismissNotification, notify } from "../../dependencies/globals.js"; -import { merge, randomizeElements, mapSessions } from "./utils.js"; +import { randomizeElements, mapSessions } from "./utils.js"; import { ProgressBar } from "../ProgressBar"; import { resolveResults } from "./guided-mode/data/utils.js"; 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 ebf13eccb..876b639e4 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -11,6 +11,21 @@ import { SimpleTable } from "../../../SimpleTable.js"; import { onThrow } from "../../../../errors"; import { merge } from "../../utils.js"; import { NWBFilePreview } from "../../../preview/NWBFilePreview.js"; +import { header } from "../../../forms/utils"; + +import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; +import { Button } from "../../../Button.js"; +import { globalSchema } from "../../../../../../../schemas/base-metadata.schema"; + +const propsToIgnore = [ + "Ophys", // Always ignore ophys metadata (for now) + "Icephys", // Always ignore icephys metadata (for now) + "Behavior", // Always ignore behavior metadata (for now) + new RegExp("ndx-.+"), // Ignore all ndx extensions + "subject_id", + "session_id", +]; + import { preprocessMetadataSchema } from "../../../../../../../schemas/base-metadata.schema"; const getInfoFromId = (key) => { @@ -33,6 +48,15 @@ export class GuidedMetadataPage extends ManagedPage { form; header = { + controls: [ + new Button({ + label: "Edit Global Metadata", + onClick: () => { + this.#globalModal.form.results = merge(this.info.globalState.project, {}); + this.#globalModal.open = true; + }, + }), + ], subtitle: "Edit all metadata for this conversion at the session level", }; @@ -61,6 +85,30 @@ export class GuidedMetadataPage extends ManagedPage { }, }; + #globalModal = null; + + connectedCallback() { + super.connectedCallback(); + + const modal = (this.#globalModal = createGlobalFormModal.call(this, { + header: "Edit Global Metadata", + propsToRemove: [...propsToIgnore], + schema: globalSchema, // Provide HARDCODED global schema for metadata properties (not automatically abstracting across sessions)... + hasInstances: true, + mergeFunction: function (globalResolved, globals) { + merge(globalResolved, globals); + return resolveGlobalOverrides(this.subject, globals); + }, + validateOnChange, + })); + document.body.append(modal); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#globalModal.remove(); + } + createForm = ({ subject, session, info }) => { // const results = createResults({ subject, info }, this.info.globalState); @@ -111,14 +159,10 @@ export class GuidedMetadataPage extends ManagedPage { results, globals: aggregateGlobalMetadata, - ignore: [ - "Ophys", // Always ignore ophys metadata (for now) - "Icephys", // Always ignore icephys metadata (for now) - "Behavior", // Always ignore behavior metadata (for now) - new RegExp("ndx-.+"), // Ignore all ndx extensions - "subject_id", - "session_id", - ], + ignore: propsToIgnore, + onOverride: (name) => { + this.notify(`${header(name)} has been overriden with a global value.`, "warning", 3000); + }, conditionalRequirements: [ { 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 f54cc7d6d..cf4d00a3f 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -8,6 +8,19 @@ import { onThrow } from "../../../../errors"; import { merge } from "../../utils.js"; import preprocessSourceDataSchema from "../../../../../../../schemas/source-data.schema"; +import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; +import { header } from "../../../forms/utils"; +import { Button } from "../../../Button.js"; + +const propsToIgnore = [ + "verbose", + "es_key", + "exclude_shanks", + "load_sync_channel", + "stream_id", // NOTE: May be desired for other interfaces + "nsx_override", +]; + export class GuidedSourceDataPage extends ManagedPage { constructor(...args) { super(...args); @@ -18,8 +31,17 @@ export class GuidedSourceDataPage extends ManagedPage { }; header = { + controls: [ + new Button({ + label: "Edit Global Metadata", + onClick: () => { + this.#globalModal.form.results = merge(this.info.globalState.project?.SourceData, {}); + this.#globalModal.open = true; + }, + }), + ], subtitle: - "Specify the file and folder locations on your local system for each interface, as well as any additional details that might be required", + "Specify the file and folder locations on your local system for each interface, as well as any additional details that might be required.", }; footer = { @@ -115,14 +137,11 @@ export class GuidedSourceDataPage extends ManagedPage { schema: preprocessSourceDataSchema(schema), results: info.source_data, emptyMessage: "No source data required for this session.", - ignore: [ - "verbose", - "es_key", - "exclude_shanks", - "load_sync_channel", - "stream_id", // NOTE: May be desired for other interfaces - "nsx_override", - ], + ignore: propsToIgnore, + globals: this.info.globalState.project.SourceData, + onOverride: (name) => { + this.notify(`${header(name)} has been overriden with a global value.`, "warning", 3000); + }, // onlyRequired: true, onUpdate: () => (this.unsavedUpdates = true), onStatusChange: (state) => this.manager.updateState(instanceId, state), @@ -138,6 +157,25 @@ export class GuidedSourceDataPage extends ManagedPage { }; }; + #globalModal = null; + + connectedCallback() { + super.connectedCallback(); + const modal = (this.#globalModal = createGlobalFormModal.call(this, { + header: "Edit Global Source Data", + propsToRemove: [...propsToIgnore, "folder_path", "file_path"], + key: "SourceData", + schema: this.info.globalState.schema.source_data, + hasInstances: true, + })); + document.body.append(modal); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#globalModal.remove(); + } + render() { this.localState = { results: merge(this.info.globalState.results, {}) }; 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 1513eeeb3..b31a389cc 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/GuidedInspectorPage.js +++ b/src/renderer/src/stories/pages/guided-mode/options/GuidedInspectorPage.js @@ -15,6 +15,8 @@ import { InstanceManager } from "../../../InstanceManager.js"; import { path as nodePath } from "../../../../electron"; import { getMessageType } from "../../../../validation/index.js"; +import { InfoBox } from "../../../InfoBox"; + const filter = (list, toFilter) => { return list.filter((o) => { return Object.entries(toFilter) @@ -36,7 +38,7 @@ export class GuidedInspectorPage extends Page { } header = { - subtitle: `The NWB Inspector has scanned your files for adherence to best practices`, + subtitle: `The NWB Inspector has scanned your files for adherence to best practices.`, controls: () => html`previous page to fix any issues shared + across files.`, + })} + ${until( (async () => { if (fileArr.length <= 1) { const items = diff --git a/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js b/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js index 7e44b9b5a..6939693bb 100644 --- a/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js +++ b/src/renderer/src/stories/pages/guided-mode/setup/GuidedNewDatasetInfo.js @@ -9,33 +9,11 @@ import projectGlobalSchema from "../../../../../../../schemas/json/project/globa import { merge } from "../../utils.js"; import { schemaToPages } from "../../FormPage.js"; import { onThrow } from "../../../../errors"; -import baseMetadataSchema from "../../../../../../../schemas/base-metadata.schema"; - -const changesAcrossSessions = { - Subject: ["weight", "subject_id", "age", "date_of_birth", "age__reference"], - NWBFile: [ - "session_id", - "session_start_time", - "identifier", - "data_collection", - "notes", - "pharmacolocy", - "session_description", - "slices", - "source_script", - "source_script_file_name", - ], -}; -const projectMetadataSchema = merge(projectGlobalSchema, projectGeneralSchema); - -Object.entries(baseMetadataSchema.properties).forEach(([globalProp, v]) => { - const info = (projectMetadataSchema.properties[globalProp] = structuredClone(v)); +import { globalSchema } from "../../../../../../../schemas/base-metadata.schema"; +import { header } from "../../../forms/utils"; - changesAcrossSessions[globalProp]?.forEach((prop) => { - delete info.properties[prop]; - }); -}); +const projectMetadataSchema = merge(projectGlobalSchema, projectGeneralSchema); export class GuidedNewDatasetPage extends Page { constructor(...args) { @@ -108,16 +86,10 @@ export class GuidedNewDatasetPage extends Page { this.state = merge(global.data.output_locations, structuredClone(this.info.globalState.project)); - const pages = schemaToPages.call( - this, - schema, - ["project"], - { validateEmptyValues: false, requirementMode: "loose" }, - (info) => { - info.title = `${info.label} Global Metadata`; - return info; - } - ); + const pages = schemaToPages.call(this, globalSchema, ["project"], { validateEmptyValues: false }, (info) => { + info.title = `${info.label} Global Metadata`; + return info; + }); pages.forEach((page) => { page.header = { @@ -129,10 +101,13 @@ export class GuidedNewDatasetPage extends Page { this.form = new JSONSchemaForm({ schema, results: this.state, - validateEmptyValues: false, + // validateEmptyValues: false, dialogOptions: { properties: ["createDirectory"], }, + onOverride: (name) => { + this.notify(`${header(name)} has been overriden with a global value.`, "warning", 3000); + }, validateOnChange, onUpdate: () => (this.unsavedUpdates = true), onThrow, diff --git a/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js b/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js index c0c40066b..1dedc3117 100644 --- a/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js +++ b/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js @@ -6,6 +6,10 @@ import { Table } from "../../../Table.js"; import { updateResultsFromSubjects } from "./utils"; import { merge } from "../../utils.js"; +import { globalSchema } from "../../../../../../../schemas/base-metadata.schema"; +import { Button } from "../../../Button.js"; +import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; +import { header } from "../../../forms/utils"; export class GuidedSubjectsPage extends Page { constructor(...args) { @@ -14,6 +18,15 @@ export class GuidedSubjectsPage extends Page { header = { subtitle: "Enter all metadata known about each experiment subject", + controls: [ + new Button({ + label: "Edit Global Metadata", + onClick: () => { + this.#globalModal.form.results = merge(this.info.globalState.project?.Subject, {}); + this.#globalModal.open = true; + }, + }), + ], }; // Abort save if subject structure is invalid @@ -70,6 +83,26 @@ export class GuidedSubjectsPage extends Page { super.updated(); // Call if updating data } + #globalModal; + + connectedCallback() { + super.connectedCallback(); + const modal = (this.#globalModal = createGlobalFormModal.call(this, { + header: "Edit Global Subject Data", + key: "Subject", + schema: globalSchema.properties.Subject, + validateOnChange: (key, parent, path) => { + return validateOnChange(key, parent, ["Subject", ...path]); + }, + })); + document.body.append(modal); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#globalModal.remove(); + } + render() { const subjects = (this.localState = merge(this.info.globalState.subjects ?? {}, {})); @@ -87,12 +120,15 @@ export class GuidedSubjectsPage extends Page { this.table = new Table({ schema: subjectSchema, data: subjects, - template: this.info.globalState.project.Subject, + globals: this.info.globalState.project.Subject, keyColumn: "subject_id", validateEmptyCells: false, contextMenu: { ignore: ["row_below"], }, + onOverride: (name) => { + this.notify(`${header(name)} has been overriden with a global value.`, "warning", 3000); + }, onUpdate: () => { this.unsavedUpdates = true; }, diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index fb21e4c38..03b08ef0b 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -16,18 +16,10 @@ const schema = { import { Button } from "../../Button.js"; import { global } from "../../../progress/index.js"; -import { merge } from "../utils.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils.js"; import { notyf } from "../../../dependencies/globals.js"; -const setUndefinedIfNotDeclared = (schemaProps, resolved) => { - for (const prop in schemaProps) { - const propInfo = schemaProps[prop]?.properties; - if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]); - else if (!(prop in resolved)) resolved[prop] = undefined; - } -}; - export class SettingsPage extends Page { header = { title: "App Settings", diff --git a/src/renderer/src/stories/pages/utils.js b/src/renderer/src/stories/pages/utils.js index 5f7b539aa..4d9ac99fd 100644 --- a/src/renderer/src/stories/pages/utils.js +++ b/src/renderer/src/stories/pages/utils.js @@ -18,13 +18,23 @@ const isObject = (o) => { return o && typeof o === "object" && !Array.isArray(o); }; +export const setUndefinedIfNotDeclared = (schemaProps, resolved) => { + if ("properties" in schemaProps) schemaProps = schemaProps.properties; + for (const prop in schemaProps) { + const propInfo = schemaProps[prop]?.properties; + if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]); + else if (!(prop in resolved)) resolved[prop] = undefined; + } +}; + export function merge(toMerge = {}, target = {}, mergeOpts = {}) { // Deep merge objects for (const [k, v] of Object.entries(toMerge)) { const targetV = target[k]; if (mergeOpts.arrays && Array.isArray(v) && Array.isArray(targetV)) target[k] = [...targetV, ...v]; // Merge array entries together - else if (isObject(v) || isObject(targetV)) target[k] = merge(v, target[k]); + else if (isObject(v) || isObject(targetV)) target[k] = merge(v, target[k], mergeOpts); + else if (v === undefined) delete target[k]; // Remove matched values else target[k] = v; // Replace primitive values }