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
})