diff --git a/schemas/base-metadata.schema.ts b/schemas/base-metadata.schema.ts index 87d237af9..fc7ccf792 100644 --- a/schemas/base-metadata.schema.ts +++ b/schemas/base-metadata.schema.ts @@ -5,6 +5,14 @@ export const preprocessMetadataSchema = (schema: any = baseMetadataSchema) => { // Add unit to weight schema.properties.Subject.properties.weight.unit = 'kg' + schema.properties.Subject.properties.sex.enumLabels = { + M: 'Male', + F: 'Female', + U: 'Unknown', + O: 'Other' + } + + // Override description of keywords schema.properties.NWBFile.properties.keywords.description = 'Terms to describe your dataset (e.g. Neural circuits, V1, etc.)' // Add description to keywords return schema diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index 05d5fdda8..21d214573 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -501,6 +501,8 @@ export class JSONSchemaForm extends LitElement { } }; + // willValidateWhenEmpty = (k) => (Array.isArray(this.validateEmptyValues) && this.validateEmptyValues.includes(k)) || this.validateEmptyValues; + #validateRequirements = async (resolved = this.resolved, requirements = this.#requirements, parentPath) => { let invalid = []; diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index f2ae8b2da..6029bff6f 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -333,7 +333,10 @@ export class JSONSchemaInput extends LitElement { > ${info.enum.map( - (item, i) => html`` + (item, i) => + html`` )} `; diff --git a/src/renderer/src/stories/Table.js b/src/renderer/src/stories/Table.js index 968d3577f..e88abb036 100644 --- a/src/renderer/src/stories/Table.js +++ b/src/renderer/src/stories/Table.js @@ -1,5 +1,4 @@ import { LitElement, html } from "lit"; -import { notify } from "../dependencies/globals"; import { Handsontable, css } from "./hot"; import { header } from "./forms/utils"; import { errorHue, warningHue } from "./globals"; @@ -77,6 +76,7 @@ export class Table extends LitElement { onOverride, validateEmptyCells, onStatusChange, + onThrow, contextMenu, } = {}) { super(); @@ -87,6 +87,7 @@ export class Table extends LitElement { this.validateEmptyCells = validateEmptyCells ?? true; this.contextMenu = contextMenu ?? {}; + if (onThrow) this.onThrow = onThrow; if (onUpdate) this.onUpdate = onUpdate; if (onOverride) this.onOverride = onOverride; if (validateOnChange) this.validateOnChange = validateOnChange; @@ -138,6 +139,12 @@ export class Table extends LitElement { validate = () => { let message; + const nUnresolved = Object.keys(this.unresolved).length; + if (nUnresolved) + message = `${nUnresolved} row${nUnresolved > 1 ? "s are" : " is"} missing a ${ + this.keyColumn ? `${header(this.keyColumn)} ` : "n " + }entry`; + if (!message) { const errors = this.querySelectorAll("[error]"); const len = errors.length; @@ -145,12 +152,6 @@ export class Table extends LitElement { else if (len) message = `${len} errors exist on this table.`; } - const nUnresolved = Object.keys(this.unresolved).length; - if (nUnresolved) - message = `${nUnresolved} row${nUnresolved > 1 ? "s are" : " is"} missing a ${ - this.keyColumn ? `${header(this.keyColumn)} ` : "n " - }entry`; - if (message) throw new Error(message); }; @@ -158,6 +159,7 @@ export class Table extends LitElement { onStatusChange = () => {}; onUpdate = () => {}; onOverride = () => {}; + onThrow = () => {}; isRequired = (col) => { return this.schema?.required?.includes(col); @@ -166,6 +168,8 @@ export class Table extends LitElement { updated() { const div = (this.shadowRoot ?? this).querySelector("div"); + const unresolved = (this.unresolved = {}); + const entries = { ...this.schema.properties }; // Add existing additional properties to the entries variable if necessary @@ -223,7 +227,7 @@ export class Table extends LitElement { // Enumerate Possible Values if (colInfo.enum) { - info.source = colInfo.enum; + info.source = colInfo.enumLabels ? Object.values(colInfo.enumLabels) : colInfo.enum; if (colInfo.strict === false) info.type = "autocomplete"; else info.type = "dropdown"; } @@ -266,44 +270,76 @@ export class Table extends LitElement { const isRequired = this.isRequired(k); const validator = async function (value, callback) { - if (!value) { - if (!ogThis.validateEmptyCells) { - ogThis.#handleValidationResult( - [], // Clear errors - this.row, - this.col - ); - callback(true); // Allow empty value - return true; - } + const validateEmptyCells = ogThis.validateEmptyCells; + const willValidate = + validateEmptyCells === true || + (Array.isArray(validateEmptyCells) && validateEmptyCells.includes(k)); + + value = ogThis.#getValue(value, colInfo); + + // Clear empty values if not validated + if (!value && !willValidate) { + ogThis.#handleValidationResult( + [], // Clear errors + this.row, + this.col + ); + callback(true); // Allow empty value + return; + } - if (isRequired) { + if (value && k === ogThis.keyColumn && unresolved[this.row]) { + if (value in ogThis.data) { ogThis.#handleValidationResult( - [{ message: `${k} is a required property.`, type: "error" }], + [{ message: `${header(k)} already exists`, type: "error" }], this.row, this.col ); callback(false); - return true; + return; } } if (!(await runThisValidator(value, this.row, this.col))) { callback(false); - return true; + return; + } + + if (!value && isRequired) { + ogThis.#handleValidationResult( + [{ message: `${header(k)} is a required property.`, type: "error" }], + this.row, + this.col + ); + callback(false); + return; } }; if (info.validator) { const og = info.validator; info.validator = async function (value, callback) { - const called = await validator.call(this, value, callback); - if (!called) og(value, callback); + let wasCalled = false; + + const newCallback = (valid) => { + wasCalled = true; + callback(valid); + }; + + await validator.call(this, value, newCallback); + if (!wasCalled) og(value, callback); }; } else info.validator = async function (value, callback) { - const called = await validator.call(this, value, callback); - if (!called) callback(true); // Default to true if not called earlier + let wasCalled = false; + + const newCallback = (valid) => { + wasCalled = true; + callback(valid); + }; + + await validator.call(this, value, newCallback); + if (!wasCalled) callback(true); // Default to true if not called earlier }; return info; @@ -316,7 +352,15 @@ export class Table extends LitElement { const rel = TH.querySelector(".relative"); const isRequired = this.isRequired(col); - if (isRequired) rel.setAttribute("data-required", this.validateEmptyCells ? true : undefined); + if (isRequired) + rel.setAttribute( + "data-required", + this.validateEmptyCells + ? Array.isArray(this.validateEmptyCells) + ? this.validateEmptyCells.includes(col) + : true + : undefined + ); if (desc) { let span = rel.querySelector(".info"); @@ -362,8 +406,10 @@ export class Table extends LitElement { if (this.table) this.table.destroy(); + console.log("Rendered data", this.#getRenderedData(data)); + const table = new Handsontable(div, { - data, + data: this.#getRenderedData(data), // rowHeaders: rowHeaders.map(v => `sub-${v}`), colHeaders: displayHeaders, columns, @@ -384,8 +430,6 @@ export class Table extends LitElement { const menu = div.ownerDocument.querySelector(".htContextMenu"); if (menu) this.#root.appendChild(menu); // Move to style root - const unresolved = (this.unresolved = {}); - let validated = 0; const initialCellsToUpdate = data.reduce((acc, v) => acc + v.length, 0); @@ -409,9 +453,11 @@ export class Table extends LitElement { const isUserUpdate = initialCellsToUpdate <= validated; - // Transfer data to object + value = this.#getValue(value, entries[header]); + + // Transfer data to object (if valid) if (header === this.keyColumn) { - if (value && value !== rowName) { + if (isValid && value && value !== rowName) { const old = target[rowName] ?? {}; this.data[value] = old; delete target[rowName]; @@ -431,7 +477,14 @@ export class Table extends LitElement { this.onOverride(header, value, rowName); } target[rowName][header] = undefined; - } else target[rowName][header] = value === globalValue ? undefined : value; + } else { + // Correct for expected arrays (copy-paste issue) + if (entries[header]?.type === "array") { + if (value && !Array.isArray(value)) value = value.split(",").map((v) => v.trim()); + } + + target[rowName][header] = value === globalValue ? undefined : value; + } } validated++; @@ -445,7 +498,7 @@ export class Table extends LitElement { // If only one row, do not allow deletion table.addHook("beforeRemoveRow", (index, amount) => { if (nRows - amount < 1) { - notify("You must have at least one row", "error"); + this.onThrow("You must have at least one row", "error"); return false; } }); @@ -473,8 +526,31 @@ export class Table extends LitElement { data.forEach((row, i) => this.#setRow(i, row)); } + #getRenderedValue = (value, colInfo) => { + // Handle enums + if (colInfo.enumLabels) return colInfo.enumLabels[value] ?? value; + return value; + }; + + #getRenderedData = (data) => { + return Object.values(data).map((row) => + row.map((value, j) => this.#getRenderedValue(value, this.schema.properties[this.colHeaders[j]])) + ); + }; + + #getValue = (value, colInfo) => { + // Handle enums + if (colInfo.enumLabels) + return Object.keys(colInfo.enumLabels).find((k) => colInfo.enumLabels[k] === value) ?? value; + + return value; + }; + #setRow(row, data) { - data.forEach((value, j) => this.table.setDataAtCell(row, j, value)); + data.forEach((value, j) => { + value = this.#getRenderedValue(value, this.schema.properties[this.colHeaders[j]]); + this.table.setDataAtCell(row, j, value); + }); } #handleValidationResult = (result, row, prop) => { 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 36a01d298..09f4e4593 100644 --- a/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js +++ b/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js @@ -48,15 +48,6 @@ export class GuidedSubjectsPage extends Page { if (!this.localState[key]) delete globalSubjects[key]; } - const noSessions = Object.keys(this.localState).filter((sub) => !this.localState[sub].sessions?.length); - if (noSessions.length) { - const error = `${noSessions.length} subject${ - noSessions.length > 1 ? "s are" : " is" - } missing Sessions entries`; - this.notify(error, "error"); - throw new Error(error); - } - this.info.globalState.subjects = merge(this.localState, globalSubjects); // Merge the local and global states const { results, subjects } = this.info.globalState; @@ -125,10 +116,11 @@ export class GuidedSubjectsPage extends Page { data: subjects, globals: this.info.globalState.project.Subject, keyColumn: "subject_id", - validateEmptyCells: false, + validateEmptyCells: ["subject_id", "sessions"], contextMenu: { ignore: ["row_below"], }, + onThrow: (message, type) => this.notify(message, type), onOverride: (name) => { this.notify(`${header(name)} has been overriden with a global value.`, "warning", 3000); }, @@ -136,9 +128,18 @@ export class GuidedSubjectsPage extends Page { this.unsavedUpdates = true; }, validateOnChange: (key, parent, v) => { - if (key === "sessions") return true; - else { - delete parent.sessions; // Delete dessions from parent copy + if (key === "sessions") { + if (v?.length) return true; + else { + return [ + { + message: "Sessions must have at least one entry", + type: "error", + }, + ]; + } + } else { + delete parent.sessions; // Delete sessions from parent copy return validateOnChange(key, parent, ["Subject"], v); } },