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