diff --git a/.gitignore b/.gitignore index beacf96bb..827e5b48e 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ src/build .env .env.local .env.production + +# Spyder +.spyproject/ diff --git a/package.json b/package.json index ceea93333..dc69a9e93 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "nwb-guide", "productName": "NWB GUIDE", - "version": "0.0.14", + "version": "0.0.15", "description": "NWB GUIDE is a desktop app that provides a no-code user interface for converting neurophysiology data to NWB.", "main": "./build/main/main.js", "engine": { diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index 02184c90b..d8b8459dc 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -11,6 +11,7 @@ from typing import Dict, Optional from shutil import rmtree, copytree from pathlib import Path +from typing import Any, Dict, List, Optional from sse import MessageAnnouncer from .info import GUIDE_ROOT_FOLDER, STUB_SAVE_FOLDER_PATH, CONVERSION_SAVE_FOLDER_PATH @@ -18,6 +19,9 @@ announcer = MessageAnnouncer() +EXCLUDED_RECORDING_INTERFACE_PROPERTIES = ["contact_vector", "contact_shapes"] + + def is_path_contained(child, parent): parent = Path(parent) child = Path(child) @@ -302,22 +306,6 @@ def map_recording_interfaces(callback, converter): return output -def is_supported_recording_interface(recording_interface, metadata): - """ - Temporary conditioned access to functionality still in development on NeuroConv. - - Used to determine display of ecephys metadata depending on the environment. - - Alpha build release should therefore always return False for this. - """ - return ( - recording_interface - and recording_interface.get_electrode_table_json - and metadata["Ecephys"].get("Electrodes") - # and all(row.get("data_type") for row in metadata["Ecephys"]["Electrodes"]) - ) - - def get_metadata_schema(source_data: Dict[str, dict], interfaces: dict) -> Dict[str, dict]: """Function used to fetch the metadata schema from a CustomNWBConverter instantiated from the source_data.""" from neuroconv.utils import NWBMetaDataEncoder @@ -332,23 +320,41 @@ def get_metadata_schema(source_data: Dict[str, dict], interfaces: dict) -> Dict[ # Clear the Electrodes information for being set as a collection of Interfaces has_ecephys = "Ecephys" in metadata + if has_ecephys: metadata["Ecephys"]["Electrodes"] = {} + schema["properties"]["Ecephys"]["required"].append("Electrodes") ecephys_properties = schema["properties"]["Ecephys"]["properties"] original_electrodes_schema = ecephys_properties["Electrodes"] - ecephys_properties["Electrodes"] = {"type": "object", "properties": {}} + ecephys_properties["Electrodes"] = {"type": "object", "properties": {}, "required": []} def on_recording_interface(name, recording_interface): - metadata["Ecephys"]["Electrodes"][name] = recording_interface.get_electrode_table_json() + metadata["Ecephys"]["Electrodes"][name] = dict( + Electrodes=get_electrode_table_json(recording_interface), + ElectrodeColumns=get_electrode_columns_json(recording_interface), + ) - ecephys_properties["Electrodes"]["properties"][name] = { - "type": "array", - "minItems": 0, - "items": {"$ref": "#/properties/Ecephys/properties/definitions/Electrode"}, - } + ecephys_properties["Electrodes"]["properties"][name] = dict( + type="object", + properties=dict( + Electrodes={ + "type": "array", + "minItems": 0, + "items": {"$ref": "#/properties/Ecephys/properties/definitions/Electrode"}, + }, + ElectrodeColumns={ + "type": "array", + "minItems": 0, + "items": {"$ref": "#/properties/Ecephys/properties/definitions/ElectrodeColumn"}, + }, + ), + required=["Electrodes", "ElectrodeColumns"], + ) + + ecephys_properties["Electrodes"]["required"].append(name) return recording_interface @@ -364,18 +370,36 @@ def on_recording_interface(name, recording_interface): defs = ecephys_properties["definitions"] electrode_def = defs["Electrodes"] + dtype_descriptions = { + "bool": "logical", + "str": "string", + "ndarray": "n-dimensional array", + "float8": "8-bit number", + "float16": "16-bit number", + "float32": "32-bit number", + "float64": "64-bit number", + "int8": "8-bit integer", + "int16": "16-bit integer", + "int32": "32-bit integer", + "int64": "64-bit integer", + } + # NOTE: Update to output from NeuroConv - electrode_def["properties"]["dtype"] = {"type": "string", "enum": ["array", "int", "float", "bool", "str"]} + electrode_def["properties"]["data_type"] = { + "type": "string", + "strict": False, + "enum": list(dtype_descriptions.keys()), + "enumLabels": dtype_descriptions, + } # Configure electrode columns - # NOTE: Update to output ALL columns and associated dtypes... - metadata["Ecephys"]["ElectrodeColumns"] = original_electrodes_schema["default"] - ecephys_properties["ElectrodeColumns"] = {"type": "array", "items": electrode_def} - ecephys_properties["ElectrodeColumns"]["items"]["required"] = list(electrode_def["properties"].keys()) + defs["ElectrodeColumn"] = electrode_def + defs["ElectrodeColumn"]["required"] = list(electrode_def["properties"].keys()) new_electrodes_properties = { properties["name"]: {key: value for key, value in properties.items() if key != "name"} for properties in original_electrodes_schema["default"] + if properties["name"] not in EXCLUDED_RECORDING_INTERFACE_PROPERTIES } defs["Electrode"] = { @@ -533,27 +557,23 @@ def update_conversion_progress(**kwargs): else None ) - if "Ecephys" not in info["metadata"]: - info["metadata"].update(Ecephys=dict()) - # Ensure Ophys NaN values are resolved resolved_metadata = replace_none_with_nan(info["metadata"], resolve_references(converter.get_metadata_schema())) - ecephys_metadata = resolved_metadata["Ecephys"] - electrode_column_results = ecephys_metadata[ - "ElectrodeColumns" - ] # NOTE: Need more specificity from the ElectrodeColumns (e.g. dtype, not always provided...) + ecephys_metadata = resolved_metadata.get("Ecephys") - for interface_name, electrode_results in ecephys_metadata["Electrodes"].items(): - interface = converter.data_interface_objects[interface_name] + if ecephys_metadata: - # NOTE: Must have a method to update the electrode table - # interface.update_electrode_table(electrode_table_json=electrode_results, electrode_column_info=electrode_column_results) + for interface_name, interface_electrode_results in ecephys_metadata["Electrodes"].items(): + interface = converter.data_interface_objects[interface_name] - # Update with the latest metadata for the electrodes - ecephys_metadata["Electrodes"] = electrode_column_results + update_recording_properties_from_table_as_json( + interface, + electrode_table_json=interface_electrode_results["Electrodes"], + electrode_column_info=interface_electrode_results["ElectrodeColumns"], + ) - ecephys_metadata.pop("ElectrodeColumns", dict()) + del ecephys_metadata["Electrodes"] # NOTE: Not sure what this should be now... # Actually run the conversion converter.run_conversion( @@ -603,11 +623,17 @@ def upload_folder_to_dandi( cleanup: Optional[bool] = None, number_of_jobs: Optional[int] = None, number_of_threads: Optional[int] = None, + ignore_cache: bool = False, ): from neuroconv.tools.data_transfers import automatic_dandi_upload os.environ["DANDI_API_KEY"] = api_key # Update API Key + if ignore_cache: + os.environ["DANDI_CACHE"] = "ignore" + else: + os.environ["DANDI_CACHE"] = "" + return automatic_dandi_upload( dandiset_id=dandiset_id, nwb_folder_path=Path(nwb_folder_path), @@ -626,6 +652,7 @@ def upload_project_to_dandi( cleanup: Optional[bool] = None, number_of_jobs: Optional[int] = None, number_of_threads: Optional[int] = None, + ignore_cache: bool = False, ): from neuroconv.tools.data_transfers import automatic_dandi_upload @@ -633,6 +660,11 @@ def upload_project_to_dandi( os.environ["DANDI_API_KEY"] = api_key # Update API Key + if ignore_cache: + os.environ["DANDI_CACHE"] = "ignore" + else: + os.environ["DANDI_CACHE"] = "" + return automatic_dandi_upload( dandiset_id=dandiset_id, nwb_folder_path=CONVERSION_SAVE_FOLDER_PATH / project, # Scope valid DANDI upload paths to GUIDE projects @@ -908,3 +940,165 @@ def generate_test_data(output_path: str): export_to_phy( waveform_extractor=waveform_extractor, output_folder=phy_output_folder, remove_if_exists=True, copy_binary=False ) + + +def map_dtype(dtype: str) -> str: + if " Dict[str, Any]: + """A convenience function for uniformly excluding certain properties of the provided recording extractor.""" + property_names = list(recording_interface.recording_extractor.get_property_keys()) + + properties = { + property_name: recording_interface.recording_extractor.get_property(key=property_name) + for property_name in property_names + if property_name not in EXCLUDED_RECORDING_INTERFACE_PROPERTIES + } + + return properties + + +def get_electrode_columns_json(interface) -> List[Dict[str, Any]]: + """A convenience function for collecting and organizing the properties of the underlying recording extractor.""" + properties = get_recording_interface_properties(interface) + + # Hardcuded for SpikeGLX (NOTE: Update for more interfaces) + property_descriptions = dict( + channel_name="The name of this channel.", + group_name="The name of the ElectrodeGroup this channel's electrode is a part of.", + shank_electrode_number="0-based index of the electrode on the shank.", + contact_shapes="The shape of the electrode.", + inter_sample_shift="Time-delay of each channel sampling in proportion to the per-frame sampling period.", + gain_to_uV="The scaling factor from the data type to microVolts, applied before the offset.", + offset_to_uV="The offset from the data type to microVolts, applied after the gain.", + ) + + # default_column_metadata = interface.get_metadata()["Ecephys"]["ElectrodeColumns"]["properties"] # NOTE: This doesn't exist... + # property_descriptions = {column_name: column_fields["description"] for column_name, column_fields in default_column_metadata} + + recording_extractor = interface.recording_extractor + channel_ids = recording_extractor.get_channel_ids() + + electrode_columns = [ + dict( + name=property_name, + description=property_descriptions.get(property_name, "No description."), + data_type=get_property_dtype( + recording_extractor=recording_extractor, property_name=property_name, channel_ids=[channel_ids[0]] + ), + ) + for property_name in properties.keys() + ] + + # TODO: uncomment when neuroconv supports contact vectors (probe interface) + # contact_vector = properties.pop("contact_vector", None) + # if contact_vector is None: + # return json.loads(json.dumps(obj=electrode_columns)) + # # Unpack contact vector + # for property_name in contact_vector.dtype.names: + # electrode_columns.append( + # dict( + # name=property_name, + # description=property_descriptions.get(property_name, ""), + # data_type=str(contact_vector.dtype.fields[property_name][0]), + # ) + # ) + + return json.loads(json.dumps(obj=electrode_columns)) + + +def get_electrode_table_json(interface) -> List[Dict[str, Any]]: + """ + A convenience function for collecting and organizing the property values of the underlying recording extractor. + """ + + from neuroconv.utils import NWBMetaDataEncoder + + recording = interface.recording_extractor + + property_names = get_recording_interface_properties(interface) + + electrode_ids = recording.get_channel_ids() + + table = list() + for electrode_id in electrode_ids: + electrode_column = dict() + for property_name in property_names: + recording_property_value = recording.get_property(key=property_name, ids=[electrode_id])[ + 0 # First axis is always electodes in SI + ] # Since only fetching one electrode at a time, use trivial zero-index + electrode_column.update({property_name: recording_property_value}) + table.append(electrode_column) + table_as_json = json.loads(json.dumps(table, cls=NWBMetaDataEncoder)) + + return table_as_json + + +def update_recording_properties_from_table_as_json( + recording_interface, electrode_column_info: dict, electrode_table_json: List[Dict[str, Any]] +): + import numpy as np + + # # Extract contact vector properties + properties = get_recording_interface_properties(recording_interface) + + # TODO: uncomment and adapt when neuroconv supports contact vectors (probe interface) + # contact_vector = properties.pop("contact_vector", None) + # contact_vector_dtypes = {} + # if contact_vector is not None: + # # Remove names from contact vector from the electrode_column_info and add to reconstructed_contact_vector_info + # contact_vector_dtypes = contact_vector.dtype + # # contact_vector_dtypes = { property_name: next((item for item in electrode_column_info if item['name'] == property_name), None)["data_type"] for property_name in contact_vector.dtype.names} + # # Remove contact vector properties from electrode_column_info + # for property_name in contact_vector.dtype.names: + # found = next((item for item in electrode_column_info if item["name"] == property_name), None) + # if found: + # electrode_column_info.remove(found) + + # Organize dtypes + electrode_column_data_types = {column["name"]: column["data_type"] for column in electrode_column_info} + # electrode_column_data_types["contact_vector"] = contact_vector_dtypes # Provide contact vector information + + recording_extractor = recording_interface.recording_extractor + channel_ids = recording_extractor.get_channel_ids() + stream_prefix = channel_ids[0].split("#")[0] # TODO: see if this generalized across formats + + # TODO: uncomment when neuroconv supports contact vectors (probe interface) + # property_names = recording_extractor.get_property_keys() + # if "contact_vector" in property_names: + # modified_contact_vector = np.array(recording_extractor.get_property(key="contact_vector")) # copy + # contact_vector_property_names = list(modified_contact_vector.dtype.names) + + for entry_index, entry in enumerate(electrode_table_json): + electrode_properties = dict(entry) # copy + channel_name = electrode_properties.pop("channel_name") + for property_name, property_value in electrode_properties.items(): + if property_name not in electrode_column_data_types: # Skip data with missing column information + continue + # TODO: uncomment when neuroconv supports contact vectors (probe interface) + # elif property_name in contact_vector_property_names: + # property_index = contact_vector_property_names.index(property_name) + # modified_contact_vector[entry_index][property_index] = property_value + else: + recording_extractor.set_property( + key=property_name, + values=np.array([property_value], dtype=electrode_column_data_types[property_name]), + ids=[stream_prefix + "#" + channel_name], + ) + + # TODO: uncomment when neuroconv supports contact vectors (probe interface) + # if "contact_vector" in property_names: + # recording_extractor.set_property(key="contact_vector", values=modified_contact_vector) diff --git a/schemas/base-metadata.schema.ts b/schemas/base-metadata.schema.ts index 3ba69f284..150f320e0 100644 --- a/schemas/base-metadata.schema.ts +++ b/schemas/base-metadata.schema.ts @@ -101,7 +101,10 @@ export const preprocessMetadataSchema = (schema: any = baseMetadataSchema, globa // Change rendering order for electrode table columns const electrodesProp = ecephys.properties["Electrodes"] for (let name in electrodesProp.properties) { - electrodesProp.properties[name].items.order = ["channel_name", "group_name", "shank_electrode_number"]; + const interfaceProps = electrodesProp.properties[name].properties + interfaceProps["Electrodes"].items.order = ["channel_name", "group_name", "shank_electrode_number"]; + interfaceProps["ElectrodeColumns"].items.order = ["name", "description", "data_type"]; + } } diff --git a/schemas/json/dandi/upload.json b/schemas/json/dandi/upload.json index ced194f5e..6c1e60811 100644 --- a/schemas/json/dandi/upload.json +++ b/schemas/json/dandi/upload.json @@ -20,9 +20,15 @@ "min": 1, "default": 1 }, + "ignore_cache": { + "type": "boolean", + "description": "Ignore the cache used by DANDI to speed up repeated operations.", + "default": false + }, "cleanup": { "type": "boolean", - "title": "Delete Local Files After Upload", + "title": "Cleanup Local Filesystem", + "description": "Delete local files after upload", "default": false } } diff --git a/src/renderer/src/stories/BasicTable.js b/src/renderer/src/stories/BasicTable.js index 4a400334c..b6f197371 100644 --- a/src/renderer/src/stories/BasicTable.js +++ b/src/renderer/src/stories/BasicTable.js @@ -1,14 +1,15 @@ -import { LitElement, css, html } from "lit"; +import { LitElement, css, html, unsafeCSS } from "lit"; import { styleMap } from "lit/directives/style-map.js"; import { header } from "./forms/utils"; import { checkStatus } from "../validation"; -import { errorHue, warningHue } from "./globals"; +import { emojiFontFamily, errorHue, warningHue } from "./globals"; import * as promises from "../promises"; import "./Button"; import { sortTable } from "./Table"; import tippy from "tippy.js"; +import { getIgnore } from "./JSONSchemaForm"; export class BasicTable extends LitElement { static get styles() { @@ -65,6 +66,12 @@ export class BasicTable extends LitElement { user-select: none; } + .relative .info { + margin: 0px 5px; + font-size: 80%; + font-family: ${unsafeCSS(emojiFontFamily)}; + } + th span { display: inline-block; } @@ -159,8 +166,28 @@ export class BasicTable extends LitElement { }; #renderHeader = (str, { description }) => { - if (description) return html`${this.#renderHeaderContent(str)}`; - return html`${this.#renderHeaderContent(str)}`; + const th = document.createElement("th"); + + const required = this.#itemSchema.required ? this.#itemSchema.required.includes(str) : false; + const container = document.createElement("div"); + container.classList.add("relative"); + const span = document.createElement("span"); + span.textContent = header(str); + if (required) span.setAttribute("required", ""); + container.appendChild(span); + + // Add Description Tooltip + if (description) { + const span = document.createElement("span"); + span.classList.add("info"); + span.innerText = "ℹ️"; + container.append(span); + tippy(span, { content: `${description[0].toUpperCase() + description.slice(1)}`, allowHTML: true }); + } + + th.appendChild(container); + + return th; }; #getRowData(row, cols = this.colHeaders) { @@ -169,13 +196,12 @@ export class BasicTable extends LitElement { let value; if (col === this.keyColumn) { if (hasRow) value = row; - else return ""; + else return; } else value = (hasRow ? this.data[row][col] : undefined) ?? // this.globals[col] ?? - this.#itemSchema.properties[col].default ?? - ""; + this.#itemSchema.properties[col]?.default; return value; }); } @@ -210,7 +236,7 @@ export class BasicTable extends LitElement { onStatusChange = () => {}; onLoaded = () => {}; - #validateCell = (value, col, parent) => { + #validateCell = (value, col, row, parent) => { if (!value && !this.validateEmptyCells) return true; // Empty cells are valid if (!this.validateOnChange) return true; @@ -243,11 +269,12 @@ export class BasicTable extends LitElement { else if (value !== "" && type && inferredType !== type) { result = [{ message: `${col} is expected to be of type ${ogType}, not ${inferredType}`, type: "error" }]; } + // Otherwise validate using the specified onChange function - else result = this.validateOnChange(col, parent, value, this.#itemProps[col]); + else result = this.validateOnChange([row, col], parent, value, this.#itemProps[col]); // Will run synchronously if not a promise result - return promises.resolve(result, () => { + return promises.resolve(result, (result) => { let info = { title: undefined, warning: undefined, @@ -278,7 +305,7 @@ export class BasicTable extends LitElement { const results = this.#data.map((v, i) => { return v.map((vv, j) => { - const info = this.#validateCell(vv, this.colHeaders[j], { ...this.data[rows[i]] }); // Could be a promise or a basic response + const info = this.#validateCell(vv, this.colHeaders[j], i, { ...this.data[rows[i]] }); // Could be a promise or a basic response return promises.resolve(info, (info) => { if (info === true) return; const td = this.shadowRoot.getElementById(`i${i}_j${j}`); @@ -367,9 +394,11 @@ export class BasicTable extends LitElement { render() { this.#updateRendered(); + this.schema = this.schema; // Always update the schema + + console.warn("RERENDERING"); + const entries = this.#itemProps; - for (let key in this.ignore) delete entries[key]; - for (let key in this.ignore["*"] ?? {}) delete entries[key]; // Add existing additional properties to the entries variable if necessary if (this.#itemSchema.additionalProperties) { @@ -385,6 +414,10 @@ export class BasicTable extends LitElement { }, entries); } + // Ignore any additions in the ignore configuration + for (let key in this.ignore) delete entries[key]; + for (let key in this.ignore["*"] ?? {}) delete entries[key]; + // Sort Columns by Key Column and Requirement const keys = (this.#keys = @@ -419,7 +452,9 @@ export class BasicTable extends LitElement { ${data.map( (row, i) => html` - ${row.map((col, j) => html`
${col}
`)} + ${row.map( + (col, j) => html`
${JSON.stringify(col)}
` + )} ` )} diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index ed318d187..4d8efcc37 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -21,6 +21,47 @@ const encode = (str) => { } }; +export const get = (path, object, omitted = [], skipped = []) => { + // path = path.slice(this.base.length); // Correct for base path + if (!path) throw new Error("Path not specified"); + return path.reduce((acc, curr, i) => { + const tempAcc = acc?.[curr] ?? acc?.[omitted.find((str) => acc[str] && acc[str][curr])]?.[curr]; + if (tempAcc) return tempAcc; + else { + const level1 = acc?.[skipped.find((str) => acc[str])]; + if (level1) { + // Handle items-like objects + const result = get(path.slice(i), level1, omitted, skipped); + if (result) return result; + + // Handle pattern properties objects + const got = Object.keys(level1).find((key) => { + const result = get(path.slice(i + 1), level1[key], omitted, skipped); + if (result && typeof result === "object") return result; // Schema are objects... + }); + + if (got) return level1[got]; + } + } + }, object); +}; + +export const getSchema = (path, schema, base = []) => { + if (typeof path === "string") path = path.split("."); + + // NOTE: Still must correct for the base here + if (base.length) { + const indexOf = path.indexOf(base.slice(-1)[0]); + if (indexOf !== -1) path = path.slice(indexOf + 1); + } + + // NOTE: Refs are now pre-resolved + const resolved = 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; +}; + const additionalPropPattern = "additional"; const templateNaNMessage = `
Type NaN to represent an unknown value.`; @@ -253,6 +294,62 @@ export class JSONSchemaForm extends LitElement { if (props.base) this.base = props.base; } + // Handle wildcards to grab multiple form elements + getAllFormElements = (path, config = { forms: true, tables: true, inputs: true }) => { + const name = path[0]; + const upcomingPath = path.slice(1); + + const isWildcard = name === "*"; + const last = !upcomingPath.length; + + if (isWildcard) { + if (last) { + const allElements = []; + if (config.forms) allElements.push(...this.forms.values()); + if (config.tables) allElements.push(...this.tables.values()); + if (config.inputs) allElements.push(...this.inputs.values()); + return allElements; + } else + return Object.values(this.forms) + .map((form) => form.getAllFormElements(upcomingPath, config)) + .flat(); + } + + // Get Single element + else { + const result = this.#getElementOnForm(path); + if (!result) return []; + + if (last) { + if (result instanceof JSONSchemaForm && config.forms) return [result]; + else if (result instanceof JSONSchemaInput && config.inputs) return [result]; + else if (config.tables) return [result]; + + return []; + } else { + if (result instanceof JSONSchemaForm) return result.getAllFormElements(upcomingPath, config); + else return [result]; + } + } + }; + + // Single later only + #getElementOnForm = (path, { forms = true, tables = true, inputs = true } = {}) => { + if (typeof path === "string") path = path.split("."); + if (!path.length) return this; + + const name = path[0]; + + const form = this.forms[name]; + if (form && forms) return form; + + const table = this.tables[name]; + if (table && tables) return table; + + const foundInput = this.inputs[path.join(".")]; // Check Inputs + if (foundInput && inputs) return foundInput; + }; + // Get the form element defined by the path (stops before table cells) getFormElement = ( path, @@ -265,24 +362,15 @@ export class JSONSchemaForm extends LitElement { if (typeof path === "string") path = path.split("."); if (!path.length) return this; - const name = path[0]; const updatedPath = path.slice(1); - const form = this.forms[name]; // Check forms - if (!form) { - const table = this.tables[name]; // Check tables - if (table && tables) return table; // Skip table cells - } else if (!updatedPath.length && forms) return form; - - // Check Inputs - // const inputContainer = this.shadowRoot.querySelector(`#${encode(path.join("-"))}`); - // if (inputContainer && inputs) return inputContainer.querySelector("jsonschema-input");; - - const foundInput = this.inputs[path.join(".")]; // Check Inputs - if (foundInput && inputs) return foundInput; + const result = this.#getElementOnForm(path, { forms, tables, inputs }); + if (result instanceof JSONSchemaForm) { + if (!updatedPath.length) return result; + else return result.getFormElement(updatedPath, { forms, tables, inputs }); + } - // Check Nested Form Inputs - return form?.getFormElement(updatedPath, { forms, tables, inputs }); + return result; }; #requirements = {}; @@ -388,18 +476,23 @@ export class JSONSchemaForm extends LitElement { const isRow = typeof rowName === "number"; const resolvedValue = e.path.reduce((acc, token) => acc[token], resolved); + const resolvedSchema = this.getSchema(e.path, schema); // ------------ 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") + if ( + e.message.includes("is not of a type(s)") && + "properties" in resolvedSchema && + resolvedSchema.type === "string" + ) return; // Ignore required errors if value is empty if (e.name === "required" && !this.validateEmptyValues && !(e.property in e.instance)) return; // Non-Strict Rule - if (schema.strict === false && e.message.includes("is not one of enum values")) return; + if (resolvedSchema.strict === false && e.message.includes("is not one of enum values")) return; // Allow referring to floats as null (i.e. JSON NaN representation) if (e.message === "is not of a type(s) number") { @@ -505,30 +598,7 @@ export class JSONSchemaForm extends LitElement { return true; }; - #get = (path, object = this.resolved, omitted = [], skipped = []) => { - // path = path.slice(this.base.length); // Correct for base path - if (!path) throw new Error("Path not specified"); - return path.reduce((acc, curr, i) => { - const tempAcc = acc?.[curr] ?? acc?.[omitted.find((str) => acc[str] && acc[str][curr])]?.[curr]; - if (tempAcc) return tempAcc; - 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); - if (result && typeof result === "object") return result; // Schema are objects... - }); - - if (got) return level1[got]; - } - } - }, object); - }; + #get = (path, object = this.resolved, omitted = [], skipped = []) => get(path, object, omitted, skipped); #checkRequiredAfterChange = async (localPath) => { const path = [...localPath]; @@ -549,22 +619,7 @@ export class JSONSchemaForm extends LitElement { return this.#schema; } - getSchema(path, schema = this.schema) { - if (typeof path === "string") path = path.split("."); - - // NOTE: Still must correct for the base here - if (this.base.length) { - const base = this.base.slice(-1)[0]; - const indexOf = path.indexOf(base); - if (indexOf !== -1) path = path.slice(indexOf + 1); - } - - // NOTE: Refs are now pre-resolved - 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; - } + getSchema = (path, schema = this.schema) => getSchema(path, schema, this.base); #renderInteractiveElement = (name, info, required, path = [], value, propertyType) => { let isRequired = this.#isRequired([...path, name]); diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 6a55bb2de..fd09b82ba 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -30,6 +30,7 @@ function resolveDateTime(value) { export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { const name = fullPath.slice(-1)[0]; const path = fullPath.slice(0, -1); + const relativePath = this.form?.base ? fullPath.slice(this.form.base.length) : fullPath; const schema = this.schema; const validateOnChange = this.validateOnChange; @@ -264,10 +265,10 @@ export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { ignore: nestedIgnore, // According to schema onUpdate: function () { - return onUpdate.call(this, fullPath, this.data); // Update all table data + return onUpdate.call(this, relativePath, this.data); // Update all table data }, - validateOnChange: (...args) => commonValidationFunction(fullPath, ...args), + validateOnChange: (...args) => commonValidationFunction(relativePath, ...args), ...commonTableMetadata, }; diff --git a/src/renderer/src/stories/SimpleTable.js b/src/renderer/src/stories/SimpleTable.js index 412483722..7161f52a7 100644 --- a/src/renderer/src/stories/SimpleTable.js +++ b/src/renderer/src/stories/SimpleTable.js @@ -119,8 +119,13 @@ export class SimpleTable extends LitElement { z-index: 1; } + table tr:first-child td { + border-top: 0px; + } + th { border-right: 1px solid gray; + border-bottom: 1px solid gray; color: #222; font-weight: 400; text-align: center; @@ -503,7 +508,7 @@ export class SimpleTable extends LitElement { Object.keys(cols).map((k) => (cols[k] = "")); if (this.validateOnChange) Object.keys(cols).map((k) => { - const res = this.validateOnChange(k, { ...cols }, cols[k]); + const res = this.validateOnChange([k], { ...cols }, cols[k]); if (typeof res === "function") res(); }); diff --git a/src/renderer/src/stories/Table.js b/src/renderer/src/stories/Table.js index ec956b466..27745fb5b 100644 --- a/src/renderer/src/stories/Table.js +++ b/src/renderer/src/stories/Table.js @@ -295,7 +295,7 @@ export class Table extends LitElement { try { const valid = this.validateOnChange ? await this.validateOnChange( - k, + [k], { ...this.data[rowHeaders[row]] }, // Validate on a copy of the parent value, info @@ -551,7 +551,7 @@ export class Table extends LitElement { const rowName = rowHeaders[row]; // const cols = this.data[rowHeaders[row]] // Object.keys(cols).map(k => cols[k] = '') - // if (this.validateOnChange) Object.keys(cols).map(k => this.validateOnChange(k, { ...cols }, cols[k])) // Validate with empty values before removing + // if (this.validateOnChange) Object.keys(cols).map(k => this.validateOnChange([ k ], { ...cols }, cols[k])) // Validate with empty values before removing delete this.data[rowHeaders[row]]; delete unresolved[row]; this.onUpdate(rowName, null, undefined); // NOTE: Global metadata PR might simply set all data values to undefined 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 142ccca1c..d68dae20d 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -28,7 +28,6 @@ import globalIcon from "../../../assets/global.svg?raw"; const imagingPlaneKey = "imaging_plane"; const propsToIgnore = { Ophys: { - // NOTE: Get this to work "*": { starting_time: true, rate: true, @@ -59,6 +58,13 @@ const propsToIgnore = { UnitProperties: true, ElectricalSeriesLF: true, ElectricalSeriesAP: true, + Electrodes: { + "*": { + location: true, + group: true, + contact_vector: true, + }, + }, }, Icephys: true, // Always ignore icephys metadata (for now) Behavior: true, // Always ignore behavior metadata (for now) @@ -202,6 +208,7 @@ export class GuidedMetadataPage extends ManagedPage { ); } + console.log("schema", structuredClone(schema), structuredClone(results)); // Create the form const form = new JSONSchemaForm({ identifier: instanceId, @@ -392,7 +399,7 @@ export class GuidedMetadataPage extends ManagedPage { metadata.schema = updatedSchema; // NOTE: Handsontable will occasionally have a context menu that doesn't actually trigger any behaviors - if (fullPath.slice(-1)[0] !== "Electrodes") return new SimpleTable(metadata); + if (name !== "Electrodes") return new SimpleTable(metadata); else return true; // All other tables are handled by the default behavior // if (name !== "ElectrodeColumns" && name !== "Electrodes") return new Table(metadata); }, diff --git a/src/renderer/src/validation/index.js b/src/renderer/src/validation/index.js index ae9206415..e0d819e18 100644 --- a/src/renderer/src/validation/index.js +++ b/src/renderer/src/validation/index.js @@ -24,35 +24,34 @@ export async function validateOnChange(name, parent, path, value) { else return; }, validationSchema); // Pass the top level until it runs out - let overridden = false; - // Skip wildcard check for categories marked with false if (lastResolved !== false && (functions === undefined || functions === true)) { - // let overridden = false; - let lastWildcard; - toIterate.reduce((acc, key) => { - // 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 - else { - lastWildcard = typeof acc["*"] === "string" ? acc["*"].replace(`{*}`, `${name}`) : acc["*"]; - overridden = false; // Re-enable if a new one is specified below - } - } else if (lastWildcard && typeof lastWildcard === "object") { - const newWildcard = lastWildcard[key] ?? lastWildcard["*"] ?? lastWildcard["**"] ?? (acc && acc["**"]); // Drill wildcard objects once resolved - // Prioritize continuation of last wildcard - if (newWildcard) lastWildcard = newWildcard; - } - - return acc?.[key]; - }, validationSchema); - - if (overridden && functions !== true) lastWildcard = false; // Disable if not promised to exist - - if (typeof lastWildcard === "function" || typeof lastWildcard === "string") functions = [lastWildcard]; + const getNestedMatches = (result, searchPath, toAlwaysCheck = []) => { + const matches = []; + const isUndefined = result === undefined; + if (Array.isArray(result)) matches.push(...result); + else if (result && typeof result === "object") + matches.push(...getMatches(result, searchPath, toAlwaysCheck)); + else if (!isUndefined) matches.push(result); + if (searchPath.length) + toAlwaysCheck.forEach((obj) => matches.push(...getMatches(obj, searchPath, toAlwaysCheck))); + return matches; + }; + + const getMatches = (obj = {}, searchPath, toAlwaysCheck = []) => { + const updatedAlwaysCheck = [...toAlwaysCheck]; + const updateSearchPath = [...searchPath]; + const nextToken = updateSearchPath.shift(); + const matches = []; + if (obj["*"]) matches.push(...getNestedMatches(obj["*"], updateSearchPath, updatedAlwaysCheck)); + if (obj["**"]) updatedAlwaysCheck.push(obj["**"]); + matches.push(...getNestedMatches(obj[nextToken], updateSearchPath, updatedAlwaysCheck)); // Always search to the end of the search path + return matches; + }; + + const matches = getMatches(validationSchema, toIterate); + const overridden = matches.some((match) => match === false); + functions = overridden && functions !== true ? false : matches; // Disable if not promised to exist—or use matches } if (!functions || (Array.isArray(functions) && functions.length === 0)) return; // No validation for this field @@ -63,12 +62,13 @@ export async function validateOnChange(name, parent, path, value) { if (typeof func === "function") { return func.call(this, name, copy, path, value); // Can specify alternative client-side validation } else { + const resolvedFunctionName = func.replace(`{*}`, `${name}`); return fetch(`${baseUrl}/neuroconv/validate`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ parent: copy, - function_name: func, + function_name: resolvedFunctionName, }), }) .then((res) => res.json()) diff --git a/src/renderer/src/validation/validation.json b/src/renderer/src/validation/validation.json index 96569524f..f2af26e36 100644 --- a/src/renderer/src/validation/validation.json +++ b/src/renderer/src/validation/validation.json @@ -1,10 +1,10 @@ { - "*": "check_{*}", "name": false, "conversion_output_folder": false, "NWBFile": { + "*": "check_{*}", "identifier": false, "session_description": false, "lab": false, @@ -42,6 +42,7 @@ "Behavior": false, "Subject": { + "*": "check_subject_{*}", "sessions": false, "description": false, "genotype": false, @@ -51,7 +52,6 @@ "subject_id": "check_subject_id_exists", "species": ["check_subject_species_form", "check_subject_species_exists"], "date_of_birth": false, - "age": ["check_subject_age", "check_subject_proper_age_range"], - "*": "check_subject_{*}" + "age": ["check_subject_age", "check_subject_proper_age_range"] } } diff --git a/src/renderer/src/validation/validation.ts b/src/renderer/src/validation/validation.ts index 5237b11d3..5fcd40b0b 100644 --- a/src/renderer/src/validation/validation.ts +++ b/src/renderer/src/validation/validation.ts @@ -1,15 +1,9 @@ import schema from './validation.json' -import { JSONSchemaForm } from '../stories/JSONSchemaForm.js' +import { JSONSchemaForm, getSchema } from '../stories/JSONSchemaForm' import Swal from 'sweetalert2' -function rerenderTable (this: JSONSchemaForm, linkedPath: string[]) { - const element = this.getFormElement(linkedPath) - if (element) element.requestUpdate() // Re-render table to show updates - // if (element) setTimeout(() => { - // element.requestUpdate() - // }, 100); // Re-render table to show new column - return element -} + +// ----------------- Validation Utility Functions ----------------- // const isNotUnique = (key, currentValue, rows, idx) => { @@ -24,7 +18,6 @@ const isNotUnique = (key, currentValue, rows, idx) => { type: 'error' } ] - } const get = (object: any, path: string[]) => { @@ -44,91 +37,95 @@ const get = (object: any, path: string[]) => { } } -// NOTE: Does this maintain separation between multiple sessions? -schema.Ecephys.ElectrodeGroup = { - ["*"]: { - name: function (this: JSONSchemaForm, _, __, ___, value) { - const groups = this.results.Ecephys.ElectrodeGroup.map(({ name }) => name) - // Check if the latest value will be new. Run function after validation - if (!value || !groups.includes(value)) { - return () => { - setTimeout(() => rerenderTable.call(this, ['Ecephys', 'Electrodes'])) // Allow for the updates to occur - } - } - }, - device: function (this: JSONSchemaForm, name, parent, path) { - const devices = this.results.Ecephys.Device.map(({ name }) => name) - if (devices.includes(parent[name])) return true - else { - return [ - { - message: 'Not a valid device', - type: 'error' - } - ] - } - } - } +function ensureUnique(this: JSONSchemaForm, name, parent, path, value) { + const { + values, + value: row + } = get(this.results, path) // NOTE: this.results is out of sync with the actual row contents at the moment of validation + + + if (!row) return true // Allow blank rows + + const rows = values.slice(-1)[0] + const idx = path.slice(-1)[0] + const isUniqueError = isNotUnique(name, value, rows, idx) + if (isUniqueError) return isUniqueError + + return true } -schema.Ecephys.Electrodes = { - ["*"]:{ - - // Label columns as invalid if not registered on the ElectrodeColumns table - // NOTE: If not present in the schema, these are not being rendered... - ['*']: function (this: JSONSchemaForm, name, parent, path) { - const electrodeColumns = this.results.ElectrodeColumns - if (electrodeColumns && !electrodeColumns.find((row: any) => row.name === name)) return [ - { - message: 'Not a valid column', - type: 'error' - } - ] - }, - group_name: function (this: JSONSchemaForm, _, __, ___, value) { +const getTablePathInfo = (path: string[]) => { + const modality = path[0] as Modality + const slice = path.slice(-2) + const table = slice[1] + const row = slice[2] - const groups = this.results.Ecephys.ElectrodeGroup.map(({ name }) => name) - if (groups.includes(value)) return true - else { - return [ - { - message: 'Not a valid group name', - type: 'error' - } - ] + return { modality, table, row } +} + + +// ----------------- Joint Ophys and Ecephys Validation ----------------- // + +const dependencies = { + Ophys: { + devices: [ + { + path: [ 'ImagingPlane' ], + key: 'device' + }, + { + path: [ 'TwoPhotonSeries' ], + key: 'imaging_plane' + }, + { + path: [ 'OnePhotonSeries' ], + key: 'imaging_plane' } - } + ] + }, + Ecephys: { + devices: [ + { + path: [ 'ElectrodeGroup' ], + key: 'device' + } + ], + groups: [ + { + path: [ 'Electrodes', '*', 'Electrodes' ], + key: 'group_name' + } + ] } } +type Modality = keyof typeof dependencies -// Update the columns available on the Electrodes table when there is a new name in the ElectrodeColumns table -schema.Ecephys.ElectrodeColumns = { +schema.Ophys = schema.Ecephys = { ['*']: { - ['*']: function (this: JSONSchemaForm, prop, parent, path) { - - const name = parent['name'] - if (!name) return true // Allow blank rows - - // NOTE: Reimplement across all separate tables... - // if (prop === 'name' && !(name in this.schema.properties.Ecephys.properties.Electrodes.items.properties)) { - // const element = rerender.call(this, ['Ecephys', 'Electrodes']) - // element.schema.properties[name] = {} // Ensure property is present in the schema now - // element.data.forEach(row => name in row ? undefined : row[name] = '') // Set column value as blank if not existent on row - // } - } + '**': { + ['name']: ensureUnique, + } } } -function ensureUnique(this: JSONSchemaForm, name, parent, path, value) { +async function safeRename (this: JSONSchemaForm, name, parent, path, value, options = {}) { + + const { + dependencies = {}, + swalOptions = {} + } = options + const { values, value: row } = get(this.results, path) + const info = getTablePathInfo(path) + if (!row) return true // Allow blank rows const rows = values.slice(-1)[0] @@ -136,82 +133,197 @@ function ensureUnique(this: JSONSchemaForm, name, parent, path, value) { const isUniqueError = isNotUnique(name, value, rows, idx) if (isUniqueError) return isUniqueError + const prevValue = row[name] + + if (prevValue === value || prevValue === undefined) return true // No change + + const prevUniqueError = isNotUnique(name, prevValue, rows, idx) + if (prevUniqueError) return true // Register as valid + + const resolvedSwalOptions = {} + for (const key in swalOptions) resolvedSwalOptions[key] = typeof swalOptions[key] === 'function' ? swalOptions[key](value, prevValue) : swalOptions[key] + + const result = await Swal.fire({ + ...resolvedSwalOptions, + icon: "warning", + heightAuto: false, + backdrop: "rgba(0,0,0, 0.4)", + confirmButtonText: "I understand", + showConfirmButton: true, + showCancelButton: true, + cancelButtonText: "Cancel" + }) + + if (!result.isConfirmed) return null + + // Update Dependent Tables + const modalityDependencies = dependencies[info.modality] ?? [] + + modalityDependencies.forEach(({ key, path }) => { + const fullPath = [info.modality, ...path] + const tables = this.getAllFormElements(fullPath, { tables: true }) + console.log('Got all tables', tables, fullPath) + tables.forEach(table => { + const data = table.data + data.forEach(row => { + if (row[key] === prevValue) row[key] = value + }) + table.data = data + table.requestUpdate() + }) + }) + return true } -schema.Ophys = { - ['*']: { - '**': { - ['name']: ensureUnique, +// Ophys +schema.Ophys.Device = schema.Ecephys.Device = { + ["*"]: { + + ['name']: function(...args) { + return safeRename.call(this, ...args, { + dependencies: { Ophys: dependencies.Ophys.devices, Ecephys: dependencies.Ecephys.devices }, + swalOptions: { + title: (current, prev) => `Are you sure you want to rename the ${prev} device?`, + text: () => `We will attempt to auto-update your Ophys devices to reflect this.`, + } + }) + }, + + } +} + +// ----------------- Ecephys Validation ----------------- // + +// NOTE: Does this maintain separation between multiple sessions? +schema.Ecephys.ElectrodeGroup = { + ["*"]: { + + name: function(...args) { + return safeRename.call(this, ...args, { + dependencies: { Ecephys: dependencies.Ecephys.groups }, + swalOptions: { + title: (current, prev) => `Are you sure you want to rename the ${prev} group?`, + text: () => `We will attempt to auto-update your electrode groups to reflect this.`, + } + }) + }, + + device: function (this: JSONSchemaForm, name, parent, path, value) { + const devices = this.results.Ecephys.Device.map(({ name }) => name) + + if (devices.includes(value)) return true + else { + return [ + { + message: 'Not a valid device', + type: 'error' + } + ] + } } } } -// Ophys -schema.Ophys.Device = { + +// Label columns as invalid if not registered on the ElectrodeColumns table +// NOTE: If not present in the schema, these are not being rendered... + +schema.Ecephys.Electrodes = { + + // All interfaces ["*"]: { - ['name']: async function (this: JSONSchemaForm, name, parent, path, value) { + Electrodes: { - const { - values, - value: row - } = get(this.results, path) + // All other column + ['*']: function (this: JSONSchemaForm, name, _, path) { - if (!row) return true // Allow blank rows + const commonPath = path.slice(0, -2) - const rows = values.slice(-1)[0] - const idx = path.slice(-1)[0] - const isUniqueError = isNotUnique(name, value, rows, idx) - if (isUniqueError) return isUniqueError + const colPath = [...commonPath, 'ElectrodeColumns'] - const prevValue = row[name] + const { value: electrodeColumns } = get(this.results, colPath) // NOTE: this.results is out of sync with the actual row contents at the moment of validation - if (prevValue === value || prevValue === undefined) return true // No change + if (electrodeColumns && !electrodeColumns.find((row: any) => row.name === name)) { + return [ + { + message: 'Not a valid column', + type: 'error' + } + ] + } + }, - const prevUniqueError = isNotUnique(name, prevValue, rows, idx) - if (prevUniqueError) return true // Register as valid + // Group name column + group_name: function (this: JSONSchemaForm, _, __, ___, value) { - const result = await Swal.fire({ - title: `Are you sure you want to rename the ${prevValue} device?`, - icon: "warning", - text: `We will attempt to auto-update your Ophys devices to reflect this.`, - heightAuto: false, - backdrop: "rgba(0,0,0, 0.4)", - confirmButtonText: "I understand", - showConfirmButton: true, - showCancelButton: true, - cancelButtonText: "Cancel" - }) + const groups = this.results.Ecephys.ElectrodeGroup.map(({ name }) => name) // Groups are validated across all interfaces - if (!result.isConfirmed) return null - - // Update Dependent Tables - const dependencies = [ - ['Ophys', 'ImagingPlane'], - ['Ophys', 'OnePhotonSeries'], - ['Ophys', 'TwoPhotonSeries'] - ] - - dependencies.forEach(path => { - const table = this.getFormElement(path, { tables: true }) - if (table) { - const data = table.data - data.forEach(row => { - if (row.device === prevValue) row.device = value - }) - table.data = data + if (groups.includes(value)) return true + else { + return [ + { + message: 'Not a valid group name', + type: 'error' + } + ] } + } + }, - rerenderTable.call(this, path) - }) + // Update the columns available on the Electrodes table when there is a new name in the ElectrodeColumns table + ElectrodeColumns: { + ['*']: { + '*': function (this: JSONSchemaForm, propName, __, path, value) { - return true - } + const commonPath = path.slice(0, -2) + const electrodesTablePath = [ ...commonPath, 'Electrodes'] + const electrodesTable = this.getFormElement(electrodesTablePath) + const electrodesSchema = electrodesTable.schema // Manipulate the schema that is on the table + const globalElectrodeSchema = getSchema(electrodesTablePath, this.schema) + + const { value: row } = get(this.results, path) + + const currentName = row?.['name'] + + const hasNameUpdate = propName == 'name' && !(value in electrodesSchema.items.properties) + + const resolvedName = hasNameUpdate ? value : currentName + + if (value === currentName) return true // No change + if (!resolvedName) return true // Only set when name is actually present + + const schemaToEdit = [electrodesSchema, globalElectrodeSchema] + schemaToEdit.forEach(schema => { + if (row) delete schema.items.properties[currentName] // Delete previous name from schema + + schema.items.properties[resolvedName] = { + description: propName === 'description' ? value : row?.description, + data_type: propName === 'data_type' ? value : row?.data_type + } + }) + + // Swap the new and current name information + if (hasNameUpdate) { + const electrodesTable = this.getFormElement([ ...commonPath, 'Electrodes']) + electrodesTable.data.forEach(row => { + if (!(value in row)) row[value] = row[currentName] // Initialize new column with old values + delete row[currentName] // Delete old column + }) + } + + // Always re-render the Electrodes table on column changes + electrodesTable.requestUpdate() + } + }, + } } } +// ----------------- Ophys Validation ----------------- // + schema.Ophys.ImagingPlane = { ["*"]: { device: function (this: JSONSchemaForm, name, parent, path, value) {