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
}