diff --git a/schemas/json/base_metadata_schema.json b/schemas/json/base_metadata_schema.json index 22f087f9f..8f06043c7 100644 --- a/schemas/json/base_metadata_schema.json +++ b/schemas/json/base_metadata_schema.json @@ -11,6 +11,7 @@ "required": ["session_start_time"], "properties": { "keywords": { + "title": "Keyword", "description": "Terms to search over", "type": "array", "items": { @@ -30,20 +31,24 @@ "description": "Name of person/people who performed experiment", "type": "array", "items": { + "title": "Experimenter", "type": "string", "format": "{last_name}, {first_name} {middle_name_or_initial}", "properties": { "first_name": { - "pattern": "^[A-Z][a-z,'-]+$", - "type": "string" + "pattern": "^[\\p{L}\\s\\-\\.']+$", + "type": "string", + "flags": "u" }, "last_name": { - "pattern": "^[A-Z][a-z,'-]+$", - "type": "string" + "pattern": "^[\\p{L}\\s\\-\\.']+$", + "type": "string", + "flags": "u" }, "middle_name_or_initial": { - "pattern": "^[A-Z][a-z.'-]*$", - "type": "string" + "pattern": "^[\\p{L}\\s\\-\\.']+$", + "type": "string", + "flags": "u" } }, "required": [ @@ -89,6 +94,7 @@ "type": "array", "description": "Provide a DOI for each publication.", "items": { + "title": "Related Publication", "type": "string", "format": "{doi}", "properties": { diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index c7e4d865f..351ac7f58 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -190,7 +190,7 @@ export class JSONSchemaForm extends LitElement { } base = []; - #nestedForms = {}; + forms = {}; inputs = []; tables = {}; @@ -268,7 +268,7 @@ export class JSONSchemaForm extends LitElement { const name = path[0]; const updatedPath = path.slice(1); - const form = this.#nestedForms[name]; // Check forms + const form = this.forms[name]; // Check forms if (!form) { const table = this.tables[name]; // Check tables if (table && tables) return table; // Skip table cells @@ -363,7 +363,7 @@ export class JSONSchemaForm extends LitElement { status; checkStatus = () => { checkStatus.call(this, this.#nWarnings, this.#nErrors, [ - ...Object.entries(this.#nestedForms) + ...Object.entries(this.forms) .filter(([k, v]) => { const accordion = this.#accordions[k]; return !accordion || !accordion.disabled; @@ -382,7 +382,7 @@ export class JSONSchemaForm extends LitElement { return validator .validate(resolved, schema) .errors.map((e) => { - const propName = e.path.slice(-1)[0] ?? name ?? e.property; + const propName = e.path.slice(-1)[0] ?? name ?? (e.property === "instance" ? "Form" : e.property); const rowName = e.path.slice(-2)[0]; const isRow = typeof rowName === "number"; @@ -391,6 +391,10 @@ export class JSONSchemaForm extends LitElement { // ------------ Exclude Certain Errors ------------ + // Allow for constructing types from object types + if (e.message.includes("is not of a type(s)") && "properties" in schema && schema.type === "string") + return; + // Ignore required errors if value is empty if (e.name === "required" && !this.validateEmptyValues && !(e.property in e.instance)) return; @@ -478,10 +482,10 @@ export class JSONSchemaForm extends LitElement { if (message) this.throw(message); // Validate nested forms (skip disabled) - for (let name in this.#nestedForms) { + for (let name in this.forms) { const accordion = this.#accordions[name]; if (!accordion || !accordion.disabled) - await this.#nestedForms[name].validate(resolved ? resolved[name] : undefined); // Validate nested forms too + await this.forms[name].validate(resolved ? resolved[name] : undefined); // Validate nested forms too } for (let key in this.tables) { @@ -510,10 +514,16 @@ export class JSONSchemaForm extends LitElement { else { const level1 = acc?.[skipped.find((str) => acc[str])]; if (level1) { + // Handle items-like objects + const result = this.#get(path.slice(i), level1, omitted, skipped); + if (result) return result; + + // Handle pattern properties objects const got = Object.keys(level1).find((key) => { const result = this.#get(path.slice(i + 1), level1[key], omitted, skipped); - return result; + if (result && typeof result === "object") return result; // Schema are objects... }); + if (got) return level1[got]; } } @@ -550,7 +560,7 @@ export class JSONSchemaForm extends LitElement { } // NOTE: Refs are now pre-resolved - const resolved = this.#get(path, schema, ["properties", "patternProperties"], ["patternProperties"]); + const resolved = this.#get(path, schema, ["properties", "patternProperties"], ["patternProperties", "items"]); // if (resolved?.["$ref"]) return this.getSchema(resolved["$ref"].split("/").slice(1)); // NOTE: This assumes reference to the root of the schema return resolved; @@ -596,16 +606,6 @@ export class JSONSchemaForm extends LitElement { }); this.inputs[localPath.join("-")] = interactiveInput; - // this.validateEmptyValues ? undefined : (el) => (el.value ?? el.checked) !== "" - - // const possibleInputs = Array.from(this.shadowRoot.querySelectorAll("jsonschema-input")).map(input => input.children) - // const inputs = possibleInputs.filter(el => el instanceof HTMLElement); - // const fileInputs = Array.from(this.shadowRoot.querySelectorAll("filesystem-selector") ?? []); - // const allInputs = [...inputs, ...fileInputs]; - // const filtered = filter ? allInputs.filter(filter) : allInputs; - // filtered.forEach((input) => input.dispatchEvent(new Event("change"))); - - // console.log(interactiveInput) return html`
@@ -625,7 +625,7 @@ export class JSONSchemaForm extends LitElement { nLoaded = 0; checkAllLoaded = () => { - const expected = [...Object.keys(this.#nestedForms), ...Object.keys(this.tables)].length; + const expected = [...Object.keys(this.forms), ...Object.keys(this.tables)].length; if (this.nLoaded === expected) { this.#loaded = true; this.onLoaded(); @@ -886,12 +886,10 @@ export class JSONSchemaForm extends LitElement { // Validate Regex Pattern automatically else if (schema.pattern) { - const regex = new RegExp(schema.pattern); + const regex = new RegExp(schema.pattern, schema.flags); if (!regex.test(parent[name])) { errors.push({ - message: `${schema.title ?? header(name)} does not match the required pattern (${ - schema.pattern - }).`, + message: `${schema.title ?? header(name)} does not match the required pattern (${regex}).`, type: "error", }); } @@ -1105,7 +1103,7 @@ export class JSONSchemaForm extends LitElement { const ignore = getIgnore(this.ignore, name); const ogContext = this; - const nested = (this.#nestedForms[name] = new JSONSchemaForm({ + const nested = (this.forms[name] = new JSONSchemaForm({ identifier: this.identifier, schema: info, results: { ...nestedResults }, @@ -1189,7 +1187,7 @@ export class JSONSchemaForm extends LitElement { subtitle: html`
${explicitlyRequired ? "" : enableToggleContainer}
`, - content: this.#nestedForms[name], + content: this.forms[name], // States open: oldStates?.open ?? !hasMany, @@ -1329,9 +1327,7 @@ export class JSONSchemaForm extends LitElement { // Check if everything is internally rendered get rendered() { const isRendered = resolve(this.#rendered, () => - Promise.all( - [...Object.values(this.#nestedForms), ...Object.values(this.tables)].map(({ rendered }) => rendered) - ) + Promise.all([...Object.values(this.forms), ...Object.values(this.tables)].map(({ rendered }) => rendered)) ); return isRendered; } diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index aae940a4b..7baeff74c 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -573,7 +573,7 @@ export class JSONSchemaInput extends LitElement { return this.onValidate ? this.onValidate() : this.form?.triggerValidation - ? this.form.triggerValidation(name, path, this) + ? this.form.triggerValidation(name, path, undefined, this) : ""; }; @@ -639,14 +639,13 @@ export class JSONSchemaInput extends LitElement { new Button({ label: "Edit", size: "small", - onClick: () => { + onClick: () => this.#createModal({ key, schema: isAdditionalProperties(this.pattern) ? undefined : schema, results: value, list: list ?? this.#list, - }); - }, + }), }), ], }; @@ -659,15 +658,14 @@ export class JSONSchemaInput extends LitElement { }) : []; } - - return items; } - #schemaElement; #modal; - async #createModal({ key, schema = {}, results, list } = {}) { - const createNewObject = !results; + #createModal({ key, schema = {}, results, list, label } = {}) { + const schemaCopy = structuredClone(schema); + + const createNewObject = !results && (schemaCopy.type === "object" || schemaCopy.properties); // const schemaProperties = Object.keys(schema.properties ?? {}); // const additionalProperties = Object.keys(results).filter((key) => !schemaProperties.includes(key)); @@ -675,12 +673,10 @@ export class JSONSchemaInput extends LitElement { const allowPatternProperties = isPatternProperties(this.pattern); const allowAdditionalProperties = isAdditionalProperties(this.pattern); - const creatNewPatternProperty = allowPatternProperties && createNewObject; - - const schemaCopy = structuredClone(schema); + const createNewPatternProperty = allowPatternProperties && createNewObject; // Add a property name entry to the schema - if (creatNewPatternProperty) { + if (createNewPatternProperty) { schemaCopy.properties = { __: { title: "Property Name", type: "string", pattern: this.pattern }, ...schemaCopy.properties, @@ -695,10 +691,13 @@ export class JSONSchemaInput extends LitElement { primary: true, }); - const updateTarget = results ?? {}; + const isObject = schemaCopy.type === "object" || schemaCopy.properties; // NOTE: For formatted strings, this is not an object + + // NOTE: Will be replaced by single instances + let updateTarget = results ?? (isObject ? {} : undefined); - submitButton.addEventListener("click", async () => { - if (this.#schemaElement instanceof JSONSchemaForm) await this.#schemaElement.validate(); + submitButton.onClick = async () => { + await nestedModalElement.validate(); let value = updateTarget; @@ -713,19 +712,17 @@ export class JSONSchemaInput extends LitElement { return this.#modal.toggle(false); // Add to the list - if (createNewObject) { - if (creatNewPatternProperty) { - const key = value.__; - delete value.__; - list.add({ key, value }); - } else list.add({ key, value }); - } else list.requestUpdate(); + if (createNewPatternProperty) { + const key = value.__; + delete value.__; + list.add({ key, value }); + } else list.add({ key, value }); this.#modal.toggle(false); - }); + }; this.#modal = new Modal({ - header: key ? header(key) : "Property Definition", + header: label ? `${header(label)} Editor` : key ? header(key) : `Property Editor`, footer: submitButton, showCloseButton: createNewObject, }); @@ -733,9 +730,9 @@ export class JSONSchemaInput extends LitElement { const div = document.createElement("div"); div.style.padding = "25px"; - const isObject = schemaCopy.type === "object" || schemaCopy.properties; // NOTE: For formatted strings, this is not an object + const inputTitle = header(schemaCopy.title ?? label ?? "Value"); - this.#schemaElement = isObject + const nestedModalElement = isObject ? new JSONSchemaForm({ schema: schemaCopy, results: updateTarget, @@ -748,26 +745,34 @@ export class JSONSchemaInput extends LitElement { renderTable: this.renderTable, onThrow: this.#onThrow, }) - : new JSONSchemaInput({ - schema: schemaCopy, - validateOnChange: allowAdditionalProperties, - path: this.path, - form: this.form, - value: updateTarget, - renderTable: this.renderTable, - onUpdate: (value) => { + : new JSONSchemaForm({ + schema: { + properties: { + [tempPropertyKey]: { + ...schemaCopy, + title: inputTitle, + }, + }, + required: [tempPropertyKey], + }, + results: updateTarget, + onUpdate: (_, value) => { if (createNewObject) updateTarget[key] = value; - else this.#updateData(key, value); // NOTE: Untested + else updateTarget = value; }, + // renderTable: this.renderTable, + // onThrow: this.#onThrow, }); - div.append(this.#schemaElement); + div.append(nestedModalElement); this.#modal.append(div); document.body.append(this.#modal); setTimeout(() => this.#modal.toggle(true)); + + return this.#modal; } #getType = (value = this.value) => (Array.isArray(value) ? "array" : typeof value); @@ -937,9 +942,8 @@ export class JSONSchemaInput extends LitElement { submessage: "They don't have a predictable structure.", }); - addButton.addEventListener("click", () => { - this.#createModal({ list, schema: allowPatternProperties ? schema : itemSchema }); - }); + addButton.onClick = () => + this.#createModal({ label: name, list, schema: allowPatternProperties ? schema : itemSchema }); return html`
validateOnChange && this.#triggerValidation(name, path)}> diff --git a/src/renderer/src/validation/index.js b/src/renderer/src/validation/index.js index 524795f83..ae9206415 100644 --- a/src/renderer/src/validation/index.js +++ b/src/renderer/src/validation/index.js @@ -31,7 +31,9 @@ export async function validateOnChange(name, parent, path, value) { // let overridden = false; let lastWildcard; toIterate.reduce((acc, key) => { - if (acc && "*" in acc) { + // Disable the value is a hardcoded list of functions + a wildcard has already been specified + if (acc && lastWildcard && Array.isArray(acc[key] ?? {})) overridden = true; + else if (acc && "*" in acc) { if (acc["*"] === false && lastWildcard) overridden = true; // Disable if false and a wildcard has already been specified // Otherwise set the last wildcard diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 0c30f2f62..b53e7f083 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -7,12 +7,13 @@ import baseMetadataSchema from '../schemas/base-metadata.schema' import { createMockGlobalState } from './utils' import { Validator } from 'jsonschema' -import { textToArray } from '../src/renderer/src/stories/forms/utils' +import { tempPropertyKey, textToArray } from '../src/renderer/src/stories/forms/utils' import { updateResultsFromSubjects } from '../src/renderer/src/stories/pages/guided-mode/setup/utils' import { JSONSchemaForm } from '../src/renderer/src/stories/JSONSchemaForm' import { validateOnChange } from "../src/renderer/src/validation/index.js"; import { SimpleTable } from '../src/renderer/src/stories/SimpleTable' +import { JSONSchemaInput } from '../src/renderer/src/stories/JSONSchemaInput.js' function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); @@ -20,6 +21,8 @@ function sleep(ms) { var validator = new Validator(); +const NWBFileSchemaProperties = baseMetadataSchema.properties.NWBFile.properties + describe('metadata is specified correctly', () => { test('session-specific metadata is merged with project and subject metadata correctly', () => { @@ -58,7 +61,7 @@ test('inter-table updates are triggered', async () => { const results = { Ecephys: { // NOTE: This layer is required to place the properties at the right level for the hardcoded validation function - ElectrodeGroup: [ { name: 's1' } ], + ElectrodeGroup: [{ name: 's1' }], Electrodes: [{ group_name: 's1' }] } } @@ -117,7 +120,7 @@ test('inter-table updates are triggered', async () => { await form.rendered // Validate that the results are incorrect - const errors = await form.validate().catch(() => true).catch(() => true) + const errors = await form.validate().catch(() => true).catch(() => true) expect(errors).toBe(true) // Is invalid // Update the table with the missing electrode group @@ -138,47 +141,164 @@ test('inter-table updates are triggered', async () => { expect(hasErrors).toBe(false) // Is valid }) +const popupSchemas = { + "type": "object", + "required": ["keywords", "experimenter"], + "properties": { + "keywords": NWBFileSchemaProperties.keywords, + "experimenter": NWBFileSchemaProperties.experimenter + } +} -// TODO: Convert an integration -test('changes are resolved correctly', async () => { +// Pop-up inputs and forms work correctly +test('pop-up inputs work correctly', async () => { const results = {} + + // Create the form + const form = new JSONSchemaForm({ schema: popupSchemas, results }) + + document.body.append(form) + + await form.rendered + + // Validate that the results are incorrect + let errors = false + await form.validate().catch(() => errors = true) + expect(errors).toBe(true) // Is invalid + + + // Validate that changes to experimenter are valid + const experimenterInput = form.getFormElement(['experimenter']) + const experimenterButton = experimenterInput.shadowRoot.querySelector('nwb-button') + const experimenterModal = experimenterButton.onClick() + const experimenterNestedElement = experimenterModal.children[0].children[0] + const experimenterSubmitButton = experimenterModal.footer + + await sleep(1000) + + let modalFailed + try { + await experimenterSubmitButton.onClick() + modalFailed = false + } catch (e) { + modalFailed = true + } + + expect(modalFailed).toBe(true) // Is invalid + + experimenterNestedElement.updateData(['first_name'], 'Garrett') + experimenterNestedElement.updateData(['last_name'], 'Flynn') + + experimenterNestedElement.requestUpdate() + + await experimenterNestedElement.rendered + + try { + await experimenterSubmitButton.onClick() + modalFailed = false + } catch (e) { + modalFailed = true + } + + expect(modalFailed).toBe(false) // Is valid + + // Validate that changes to keywords are valid + const keywordsInput = form.getFormElement(['keywords']) + const keywordsButton = keywordsInput.shadowRoot.querySelector('nwb-button') + const keywordsModal = keywordsButton.onClick() + const keywordsNestedElement = keywordsModal.children[0].children[0] + const keywordsSubmitButton = keywordsModal.footer + + // No empty keyword + try { + await keywordsSubmitButton.onClick() + modalFailed = false + } catch (e) { + modalFailed = true + } + + expect(modalFailed).toBe(true) // Is invalid + + keywordsNestedElement.updateData([tempPropertyKey], 'test') + + keywordsNestedElement.requestUpdate() + + await keywordsNestedElement.rendered + + try { + await keywordsSubmitButton.onClick() + modalFailed = false + } catch (e) { + modalFailed = true + } + + expect(modalFailed).toBe(false) // Is valid + + // Validate that the new structure is correct + const hasErrors = await form.validate(form.results).then(res => false).catch(() => true) + + expect(hasErrors).toBe(false) // Is valid +}) + + +// TODO: Convert an integration +test('inter-table updates are triggered', async () => { + + const results = { + Ecephys: { // NOTE: This layer is required to place the properties at the right level for the hardcoded validation function + ElectrodeGroup: [{ name: 's1' }], + Electrodes: [{ group_name: 's1' }] + } + } + const schema = { properties: { - v0: { - type: 'string' - }, - l1: { - type: "object", + Ecephys: { properties: { - l2: { - type: "object", - properties: { - l3: { - type: "object", - properties: { - v2: { - type: 'string' - } + ElectrodeGroup: { + type: "array", + items: { + required: ["name"], + properties: { + name: { + type: "string" }, - required: ['v2'] }, + type: "object", }, }, - v1: { - type: 'string' - } - }, - required: ['v1'] + Electrodes: { + type: "array", + items: { + type: "object", + properties: { + group_name: { + type: "string", + }, + }, + } + }, + } } - }, - required: ['v0'] + } } + + + // Add invalid electrode + const randomStringId = Math.random().toString(36).substring(7) + results.Ecephys.Electrodes.push({ group_name: randomStringId }) + // Create the form const form = new JSONSchemaForm({ schema, - results + results, + validateOnChange, + renderTable: (name, metadata, path) => { + if (name !== "Electrodes") return new SimpleTable(metadata); + else return true + }, }) document.body.append(form) @@ -186,20 +306,23 @@ test('changes are resolved correctly', async () => { await form.rendered // Validate that the results are incorrect - let errors = false - await form.validate().catch(()=> errors = true) + const errors = await form.validate().catch(() => true).catch(() => true) expect(errors).toBe(true) // Is invalid - const input1 = form.getFormElement(['v0']) - const input2 = form.getFormElement(['l1', 'v1']) - const input3 = form.getFormElement(['l1', 'l2', 'l3', 'v2']) + // Update the table with the missing electrode group + const table = form.getFormElement(['Ecephys', 'ElectrodeGroup']) // This is a SimpleTable where rows can be added + const row = table.addRow() + + const baseRow = table.getRow(0) + row.forEach((cell, i) => { + if (cell.simpleTableInfo.col === 'name') cell.setInput(randomStringId) // Set name to random string id + else cell.setInput(baseRow[i].value) // Otherwise carry over info + }) - input1.updateData('test') - input2.updateData('test') - input3.updateData('test') + // 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 - const hasErrors = await form.validate(form.results).then(res => false).catch(() => true) - + const hasErrors = await form.validate().then(() => false).catch((e) => true) expect(hasErrors).toBe(false) // Is valid })