diff --git a/.github/workflows/pyflask-build-and-dist-tests.yml b/.github/workflows/pyflask-build-and-dist-tests.yml index 15a222b25..e2e983010 100644 --- a/.github/workflows/pyflask-build-and-dist-tests.yml +++ b/.github/workflows/pyflask-build-and-dist-tests.yml @@ -81,17 +81,21 @@ jobs: with: node-version: "18" + - run: npm ci + - name: Build PyFlask distribution run: npm run build:flask:${{ matrix.shorthand }} - # encountering parsing issue for second arg #- if: matrix.os == 'windows-latest' # name: Run test on build executable - # run: node tests/testPyinstallerExecutable.js ./build/nwb-guide/nwb-guide.exe + # run: node tests/testPyinstallerExecutable.js --script ./build/nwb-guide/nwb-guide.exe #- if: matrix.os != 'windows-latest' # name: Run test on build executable - # run: node tests/testPyinstallerExecutable.js ./build/nwb-guide/nwb-guide + # run: node tests/testPyinstallerExecutable.js --script ./build/nwb-guide/nwb-guide - name: Run test on distributed executable run: node tests/testPyinstallerExecutable.js + + - name: Run Python tests on distributed executable + run: npm run test:executable diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e572233f0..8387c3882 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: - id: black exclude: ^docs/ - repo: https://github.com/pre-commit/mirrors-prettier - rev: "v3.0.1" + rev: "v3.0.2" hooks: - id: prettier types_or: [css, javascript] diff --git a/docs/conf.py b/docs/conf.py index 9db89e050..188bbc260 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -22,7 +22,7 @@ templates_path = ["_templates"] master_doc = "index" exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] -# html_theme = "sphinx_rtd_theme" +html_theme = "sphinx_rtd_theme" # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 diff --git a/generateInterfaceSchema.py b/generateInterfaceSchema.py new file mode 100644 index 000000000..87a35155f --- /dev/null +++ b/generateInterfaceSchema.py @@ -0,0 +1,85 @@ +from pathlib import Path +import json +from neuroconv import datainterfaces, NWBConverter + +filepath = Path("guideGlobalMetadata.json") +generatedJSONSchemaPath = Path("schemas", "json", "generated") +generatedJSONSchemaPath.mkdir(exist_ok=True, parents=True) + +f = filepath.open() +data = json.load(f) + +# Create JSON for the Schema +paths = {} +for interface in data["supported_interfaces"]: + interface_class_dict = {interface: interface} + + class CustomNWBConverter(NWBConverter): + data_interface_classes = { + custom_name: getattr(datainterfaces, interface_name) + for custom_name, interface_name in interface_class_dict.items() + } + + schema = CustomNWBConverter.get_source_schema() + + json_object = json.dumps(schema, indent=4) + paths[interface] = filepath = generatedJSONSchemaPath / f"{interface}.json" + with open(filepath, "w") as outfile: + outfile.write(json.dumps(schema, indent=4)) + + +sourceDataStoryPath = Path("src/renderer/src/stories/pages/guided-mode/SourceData.stories.js") + +importCode = "\n".join(map(lambda arr: f"import {arr[0]}Schema from '../../../../../../{arr[1]}'", paths.items())) +storyCode = "\n".join( + map( + lambda arr: f"""export const {arr[0]} = PageTemplate.bind({{}}); +const {arr[0]}GlobalCopy = JSON.parse(JSON.stringify(globalState)) +{arr[0]}GlobalCopy.interfaces.interface = {arr[0]} +{arr[0]}GlobalCopy.schema.source_data = {arr[0]}Schema +{arr[0]}.args = {{ activePage, globalState: {arr[0]}GlobalCopy }}; +""", + paths.items(), + ) +) + + +allInterfaceCode = "\n".join( + map( + lambda arr: f"globalStateCopy.schema.source_data.properties.{arr[0]} = {arr[0]}Schema.properties.{arr[0]}", + paths.items(), + ) +) + +setDummyPathCode = f""" +const results = globalStateCopy.results +for (let sub in results){{ + for (let ses in results[sub]) results[sub][ses].source_data = {{{list(paths.keys())[1]}: {{file_path: '/dummy/file/path'}}}} +}} +""" + +with open(sourceDataStoryPath, "w") as outfile: + outfile.write( + f"""import {{ globalState, PageTemplate }} from "./storyStates"; +{importCode} + +export default {{ + title: "Pages/Guided Mode/Source Data", + parameters: {{ + chromatic: {{ disableSnapshot: false }}, + }} +}}; + +const activePage = "conversion/sourcedata" + + +const globalStateCopy = JSON.parse(JSON.stringify(globalState)) +{allInterfaceCode} +{setDummyPathCode} + +export const All = PageTemplate.bind({{}}); +All.args = {{ activePage, globalState: globalStateCopy }}; + +{storyCode} +""" + ) diff --git a/package.json b/package.json index bdc9044a6..bad2cfe3e 100644 --- a/package.json +++ b/package.json @@ -15,15 +15,16 @@ "build:win": "npm run build && npm run build:flask:win && npm run build:electron:win", "build:mac": "npm run build && npm run build:flask:unix && npm run build:electron:mac", "build:linux": "npm run build && npm run build:flask:unix && npm run build:electron:linux", - "build:flask:base": "python -m PyInstaller --name nwb-guide --onedir --clean --noconfirm ./pyflask/app.py --distpath ./build/flask --collect-all nwbinspector --collect-all neuroconv --collect-all pynwb --collect-all hdmf --collect-all ci_info --hidden-import scipy._distributor_init --hidden-import scipy._lib.messagestream --hidden-import scipy._lib._ccallback --hidden-import scipy._lib._testutils", + "build:flask:base": "python -m PyInstaller --name nwb-guide --onedir --clean --noconfirm ./pyflask/app.py --distpath ./build/flask --collect-data jsonschema_specifications --collect-all nwbinspector --collect-all neuroconv --collect-all pynwb --collect-all hdmf --collect-all ci_info --hidden-import scipy._distributor_init --hidden-import scipy._lib.messagestream --hidden-import scipy._lib._ccallback --hidden-import scipy._lib._testutils", "build:flask:win": "npm run build:flask:base -- --add-data ./paths.config.json;. --add-data ./package.json;.", - "build:flask:unix": "npm run build:flask:base -- --add-data ./paths.config.json:. --add-data ./package.json:. --collect-data jsonschema_specifications --collect-all ndx_dandi_icephys", + "build:flask:unix": "npm run build:flask:base -- --add-data ./paths.config.json:. --add-data ./package.json:. --collect-all ndx_dandi_icephys", "build:electron:win": "electron-builder build --win --publish never", "build:electron:mac": "electron-builder build --mac --publish never", "build:electron:linux": "electron-builder build --linux --publish never", "test": "npm run test:app && npm run test:server", "test:app": "vitest run", "test:server": "pytest pyflask/tests/ -s", + "test:executable": "concurrently -n EXE,TEST --kill-others \"node tests/testPyinstallerExecutable.js --port 3434 --forever\" \"pytest pyflask/tests/ -s --target http://localhost:3434\"", "test:coverage": "npm run coverage:app && npm run coverage:server", "coverage:app": "vitest run --coverage", "coverage:server": "pytest pyflask/tests/ -s --cov=pyflask --cov-report=xml", @@ -51,6 +52,13 @@ "appId": "com.catalystneuro.nwbguide", "generateUpdatesFilesForAllChannels": true, "afterSign": "./notarize.js", + "fileAssociations": [ + { + "ext": "nwb", + "name": "NWB File", + "role": "Editor" + } + ], "files": [ "src", "schemas", diff --git a/pyflask/manageNeuroconv/manage_neuroconv.py b/pyflask/manageNeuroconv/manage_neuroconv.py index dd8d0c5f6..fbcfd62d8 100644 --- a/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/pyflask/manageNeuroconv/manage_neuroconv.py @@ -287,8 +287,6 @@ def update_conversion_progress(**kwargs): if "Ecephys" not in info["metadata"]: info["metadata"].update(Ecephys=dict()) - # ecephys_metadata = info["metadata"].get("Ecephys", dict()) - # if is_supported_recording_interface(recording_interface, info["metadata"]): # electrode_column_results = ecephys_metadata["ElectrodeColumns"] # electrode_results = ecephys_metadata["Electrodes"] diff --git a/pyflask/tests/conftest.py b/pyflask/tests/conftest.py index 0de8193d9..4ecf00e8b 100644 --- a/pyflask/tests/conftest.py +++ b/pyflask/tests/conftest.py @@ -2,21 +2,24 @@ import app as flask -@pytest.fixture() -def app(): - app = flask.app - app.config.update( - { - "TESTING": True, - } - ) - - yield app - - -@pytest.fixture() -def client(app): - return app.test_client() +def pytest_addoption(parser): + parser.addoption("--target", action="store", help="Run the executable instead of the standard Flask app") + + +@pytest.fixture(scope="session") +def client(request): + target = request.config.getoption("--target") + if target: + return target + else: + app = flask.app + app.config.update( + { + "TESTING": True, + } + ) + + return app.test_client() @pytest.fixture() diff --git a/pyflask/tests/test_neuroconv.py b/pyflask/tests/test_neuroconv.py index 693b51e8d..8c363c004 100644 --- a/pyflask/tests/test_neuroconv.py +++ b/pyflask/tests/test_neuroconv.py @@ -1,37 +1,12 @@ from jsonschema import validate - - -def get_converter_output_schema(interfaces: dict): - return { - "type": "object", - "properties": { - "title": {"type": "string"}, - "description": {"type": "string"}, - "version": {"type": "string"}, - "properties": { - "type": "object", - "properties": { - interface: { - "type": "object", - "properties": { - "properties": {"type": "object"}, - "required": {"type": "array"}, - }, - } - for interface in interfaces.keys() - }, - "additionalProperties": False, - }, - }, - } +from utils import get, post, get_converter_output_schema # --------------------- Tests --------------------- # Accesses the dictionary of all interfaces and their metadata def test_get_all_interfaces(client): - all_interfaces = client.get("/neuroconv", follow_redirects=True).json validate( - all_interfaces, + get("neuroconv", client), schema={ "type": "object", "patternProperties": { @@ -51,12 +26,11 @@ def test_get_all_interfaces(client): # Test single interface schema request def test_single_schema_request(client): interfaces = {"myname": "SpikeGLXRecordingInterface"} - data = client.post("/neuroconv/schema", json=interfaces, follow_redirects=True).json - validate(data, schema=get_converter_output_schema(interfaces)) + validate(post("neuroconv/schema", interfaces, client), schema=get_converter_output_schema(interfaces)) # Uses the NWBConverter Class to combine multiple interfaces def test_multiple_schema_request(client): interfaces = {"myname": "SpikeGLXRecordingInterface", "myphyinterface": "PhySortingInterface"} - data = client.post("/neuroconv/schema", json=interfaces, follow_redirects=True).json + data = post("/neuroconv/schema", interfaces, client) validate(data, schema=get_converter_output_schema(interfaces)) diff --git a/pyflask/tests/utils.py b/pyflask/tests/utils.py new file mode 100644 index 000000000..5e74d9eac --- /dev/null +++ b/pyflask/tests/utils.py @@ -0,0 +1,44 @@ +import requests + + +def get(path, client): + if isinstance(client, str): + r = requests.get(f"{client}/{path}", allow_redirects=True) + r.raise_for_status() + return r.json() + else: + return client.get(f"/{path}", follow_redirects=True).json + + +def post(path, json, client): + if isinstance(client, str): + r = requests.post(f"{client}/{path}", json=json, allow_redirects=True) + r.raise_for_status() + return r.json() + else: + return client.post(path, json=json, follow_redirects=True).json + + +def get_converter_output_schema(interfaces: dict): + return { + "type": "object", + "properties": { + "title": {"type": "string"}, + "description": {"type": "string"}, + "version": {"type": "string"}, + "properties": { + "type": "object", + "properties": { + interface: { + "type": "object", + "properties": { + "properties": {"type": "object"}, + "required": {"type": "array"}, + }, + } + for interface in interfaces.keys() + }, + "additionalProperties": False, + }, + }, + } diff --git a/schemas/json/generated/BiocamRecordingInterface.json b/schemas/json/generated/BiocamRecordingInterface.json new file mode 100644 index 000000000..8d51c7b06 --- /dev/null +++ b/schemas/json/generated/BiocamRecordingInterface.json @@ -0,0 +1,33 @@ +{ + "required": [], + "properties": { + "BiocamRecordingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + }, + "es_key": { + "type": "string", + "default": "ElectricalSeries" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/BlackrockRecordingInterface.json b/schemas/json/generated/BlackrockRecordingInterface.json new file mode 100644 index 000000000..25190ebf2 --- /dev/null +++ b/schemas/json/generated/BlackrockRecordingInterface.json @@ -0,0 +1,38 @@ +{ + "required": [], + "properties": { + "BlackrockRecordingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string", + "description": "Path to Blackrock file." + }, + "nsx_override": { + "format": "file", + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + }, + "es_key": { + "type": "string", + "default": "ElectricalSeries" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/BlackrockSortingInterface.json b/schemas/json/generated/BlackrockSortingInterface.json new file mode 100644 index 000000000..02661e794 --- /dev/null +++ b/schemas/json/generated/BlackrockSortingInterface.json @@ -0,0 +1,33 @@ +{ + "required": [], + "properties": { + "BlackrockSortingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string", + "description": "Path to Blackrock file." + }, + "sampling_frequency": { + "type": "number" + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": true + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/CellExplorerSortingInterface.json b/schemas/json/generated/CellExplorerSortingInterface.json new file mode 100644 index 000000000..c51a65d4e --- /dev/null +++ b/schemas/json/generated/CellExplorerSortingInterface.json @@ -0,0 +1,29 @@ +{ + "required": [], + "properties": { + "CellExplorerSortingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/IntanRecordingInterface.json b/schemas/json/generated/IntanRecordingInterface.json new file mode 100644 index 000000000..07904ada2 --- /dev/null +++ b/schemas/json/generated/IntanRecordingInterface.json @@ -0,0 +1,37 @@ +{ + "required": [], + "properties": { + "IntanRecordingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string" + }, + "stream_id": { + "type": "string", + "default": "0" + }, + "verbose": { + "type": "boolean", + "default": true + }, + "es_key": { + "type": "string", + "default": "ElectricalSeries" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/KiloSortSortingInterface.json b/schemas/json/generated/KiloSortSortingInterface.json new file mode 100644 index 000000000..a31d22b75 --- /dev/null +++ b/schemas/json/generated/KiloSortSortingInterface.json @@ -0,0 +1,33 @@ +{ + "required": [], + "properties": { + "KiloSortSortingInterface": { + "required": [ + "folder_path" + ], + "properties": { + "folder_path": { + "format": "directory", + "type": "string" + }, + "keep_good_only": { + "type": "boolean", + "default": false + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/NeuroScopeLFPInterface.json b/schemas/json/generated/NeuroScopeLFPInterface.json new file mode 100644 index 000000000..d42a885ab --- /dev/null +++ b/schemas/json/generated/NeuroScopeLFPInterface.json @@ -0,0 +1,32 @@ +{ + "required": [], + "properties": { + "NeuroScopeLFPInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string" + }, + "gain": { + "type": "number" + }, + "xml_file_path": { + "format": "file", + "type": "string" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/NeuroScopeRecordingInterface.json b/schemas/json/generated/NeuroScopeRecordingInterface.json new file mode 100644 index 000000000..3e95a33d0 --- /dev/null +++ b/schemas/json/generated/NeuroScopeRecordingInterface.json @@ -0,0 +1,40 @@ +{ + "required": [], + "properties": { + "NeuroScopeRecordingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string" + }, + "gain": { + "type": "number" + }, + "xml_file_path": { + "format": "file", + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + }, + "es_key": { + "type": "string", + "default": "ElectricalSeries" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/NeuroScopeSortingInterface.json b/schemas/json/generated/NeuroScopeSortingInterface.json new file mode 100644 index 000000000..680fcbce9 --- /dev/null +++ b/schemas/json/generated/NeuroScopeSortingInterface.json @@ -0,0 +1,40 @@ +{ + "required": [], + "properties": { + "NeuroScopeSortingInterface": { + "required": [ + "folder_path" + ], + "properties": { + "folder_path": { + "format": "directory", + "type": "string" + }, + "keep_mua_units": { + "type": "boolean", + "default": true + }, + "exclude_shanks": { + "type": "array" + }, + "xml_file_path": { + "format": "file", + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/OpenEphysRecordingInterface.json b/schemas/json/generated/OpenEphysRecordingInterface.json new file mode 100644 index 000000000..9accfcf55 --- /dev/null +++ b/schemas/json/generated/OpenEphysRecordingInterface.json @@ -0,0 +1,32 @@ +{ + "required": [], + "properties": { + "OpenEphysRecordingInterface": { + "required": [ + "folder_path" + ], + "properties": { + "folder_path": { + "format": "directory", + "type": "string" + }, + "stream_name": { + "type": "string" + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/PhySortingInterface.json b/schemas/json/generated/PhySortingInterface.json new file mode 100644 index 000000000..edcc2183d --- /dev/null +++ b/schemas/json/generated/PhySortingInterface.json @@ -0,0 +1,32 @@ +{ + "required": [], + "properties": { + "PhySortingInterface": { + "required": [ + "folder_path" + ], + "properties": { + "folder_path": { + "format": "directory", + "type": "string" + }, + "exclude_cluster_groups": { + "type": "array" + }, + "verbose": { + "type": "boolean", + "default": true + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/SpikeGLXNIDQInterface.json b/schemas/json/generated/SpikeGLXNIDQInterface.json new file mode 100644 index 000000000..14ad82eb8 --- /dev/null +++ b/schemas/json/generated/SpikeGLXNIDQInterface.json @@ -0,0 +1,38 @@ +{ + "required": [], + "properties": { + "SpikeGLXNIDQInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string", + "description": "Path to SpikeGLX .nidq file." + }, + "verbose": { + "type": "boolean", + "default": true + }, + "load_sync_channel": { + "type": "boolean", + "default": false + }, + "es_key": { + "type": "string", + "default": "ElectricalSeriesNIDQ" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/schemas/json/generated/SpikeGLXRecordingInterface.json b/schemas/json/generated/SpikeGLXRecordingInterface.json new file mode 100644 index 000000000..4b2034df8 --- /dev/null +++ b/schemas/json/generated/SpikeGLXRecordingInterface.json @@ -0,0 +1,33 @@ +{ + "required": [], + "properties": { + "SpikeGLXRecordingInterface": { + "required": [ + "file_path" + ], + "properties": { + "file_path": { + "format": "file", + "type": "string", + "description": "Path to SpikeGLX ap.bin or lf.bin file." + }, + "verbose": { + "type": "boolean", + "default": true + }, + "es_key": { + "type": "string" + } + }, + "type": "object", + "additionalProperties": false + } + }, + "type": "object", + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "source.schema.json", + "title": "Source data schema", + "description": "Schema for the source data, files and directories", + "version": "0.1.0" +} diff --git a/src/main/main.ts b/src/main/main.ts index 035436381..17b77d0fc 100755 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -23,13 +23,13 @@ import splashHTML from './splash-screen.html?asset' autoUpdater.channel = "latest"; - /************************************************************* * Python Process *************************************************************/ // flask setup environment variables -const PYFLASK_DIST_FOLDER_BASE = path.join('build', 'flask') +const PYFLASK_BUILD_SUBFOLDER_NAME = 'flask' +const PYFLASK_DIST_FOLDER_BASE = path.join('build', PYFLASK_BUILD_SUBFOLDER_NAME) const PY_FLASK_DIST_FOLDER = path.join('..', '..', PYFLASK_DIST_FOLDER_BASE); const PY_FLASK_FOLDER = path.join('..', '..', "pyflask"); const PYINSTALLER_NAME = "nwb-guide" @@ -40,14 +40,27 @@ let PORT: number | string | null = 4242; let selectedPort: number | string | null = null; const portRange = 100; +const isWindows = process.platform === 'win32' + let mainWindowReady = false let readyQueue: Function[] = [] -let globals = { +let globals: { + mainWindow: BrowserWindow, + python: { + status: boolean, + sent: boolean, + latestError: string + }, + mainWindowReady: boolean +} = { + + // mainWindow: undefined, python: { status: false, sent: false, + restart: false, latestError: '' }, @@ -67,7 +80,7 @@ function send(this: BrowserWindow, ...args: any[]) { return this.webContents.send(...args) } -const onWindowReady = (f: Function) => (mainWindowReady) ? f(mainWindow) : readyQueue.push(f) +const onWindowReady = (f: (win: BrowserWindow) => any) => (mainWindowReady) ? f(globals.mainWindow) : readyQueue.push(f) // Pass all important log functions to the application @@ -98,7 +111,7 @@ const pythonIsOpen = (force = false) => { const pythonIsClosed = (err = globals.python.latestError) => { onWindowReady(win => { - send.call(win, "python.closed", err) + send.call(win, globals.python.restart ? "python.restart" : "python.closed", err) globals.python.sent = true }) @@ -111,50 +124,54 @@ const pythonIsClosed = (err = globals.python.latestError) => { * @returns {boolean} True if the app is packaged, false if it is running from a dev version. */ const getPackagedPath = () => { - - const windowsPath = path.join(__dirname, PY_FLASK_DIST_FOLDER, "flask", `${PYINSTALLER_NAME}.exe`); - const unixPath = path.join(process.resourcesPath, "flask", PYINSTALLER_NAME); - - if ((process.platform === "darwin" || process.platform === "linux") && fs.existsSync(unixPath)) return unixPath; - if (process.platform === "win32" && fs.existsSync(windowsPath)) return windowsPath; + const scriptPath = isWindows ? path.join(__dirname, PY_FLASK_DIST_FOLDER, PYFLASK_BUILD_SUBFOLDER_NAME, `${PYINSTALLER_NAME}.exe`) : path.join(process.resourcesPath, PYFLASK_BUILD_SUBFOLDER_NAME, PYINSTALLER_NAME) + if (fs.existsSync(scriptPath)) return scriptPath; }; const createPyProc = async () => { - let script = getPackagedPath() || path.join(__dirname, PY_FLASK_FOLDER, "app.py"); - await killAllPreviousProcesses(); - const defaultPort = PORT as number + return new Promise(async (resolve, reject) => { + let script = getPackagedPath() || path.join(__dirname, PY_FLASK_FOLDER, "app.py"); + await killAllPreviousProcesses(); - fp(defaultPort, defaultPort + portRange) - .then(([freePort]: string[]) => { - selectedPort = freePort; + const defaultPort = PORT as number - pyflaskProcess = (script.slice(-3) === '.py') ? child_process.spawn("python", [script, freePort], {}) : child_process.spawn(`${script}`, [freePort], {}); - if (pyflaskProcess != null) { + fp(defaultPort, defaultPort + portRange) + .then(([freePort]: string[]) => { + selectedPort = freePort; - // Listen for errors from Python process - pyflaskProcess.stderr.on("data", (data: string) => { - console.error(`${data}`) - globals.python.latestError = data.toString() - }); + pyflaskProcess = (script.slice(-3) === '.py') ? child_process.spawn("python", [script, freePort], {}) : child_process.spawn(`${script}`, [freePort], {}); - pyflaskProcess.stdout.on('data', (data: string) => { - pythonIsOpen(); - console.log(`${data}`) - }); + if (pyflaskProcess != null) { - pyflaskProcess.on('close', (code: number) => { - console.error(`exit code ${code}`) - pythonIsClosed() - }); + // Listen for errors from Python process + pyflaskProcess.stderr.on("data", (data: string) => { + console.error(`${data}`) + globals.python.latestError = data.toString() + }); - } + pyflaskProcess.stdout.on('data', (data: string) => { + const isRestarting = globals.python.restart + setTimeout(() => pythonIsOpen(isRestarting), 100); // Wait just a bit to give the server some time to come online + console.log(`${data}`) + resolve(true) + }); + + pyflaskProcess.on('close', (code: number) => { + console.error(`exit code ${code}`) + pythonIsClosed() + reject() + }); + + } + }) + .catch((err: Error) => { + console.log(err); + reject(err) + }); }) - .catch((err: Error) => { - console.log(err); - }); }; /** @@ -162,31 +179,19 @@ const createPyProc = async () => { */ const exitPyProc = async () => { - // Windows does not properly shut off the python server process. This ensures it is killed. - const killPythonProcess = () => { - // kill pyproc with command line - const cmd = child_process.spawnSync("taskkill", [ - "/pid", - pyflaskProcess.pid, - "/f", - "/t", - ]); - }; - await killAllPreviousProcesses(); - // check if the platform is Windows - if (process.platform === "win32") { - killPythonProcess(); - pyflaskProcess = null; - // PORT = null; - return; - } + // Kill signal to pyproc + if (isWindows) child_process.spawnSync("taskkill", [ + "/pid", + pyflaskProcess.pid, + "/f", + "/t", + ]) // Windows does not properly shut off the python server process. This ensures it is killed. + + else pyflaskProcess.kill() - // kill signal to pyProc - pyflaskProcess.kill(); pyflaskProcess = null; - // PORT = null; }; const killAllPreviousProcesses = async () => { @@ -210,36 +215,29 @@ const killAllPreviousProcesses = async () => { await Promise.allSettled(promisesArray); }; -// 5.4.1 change: We call createPyProc in a spearate ready event -// app.on("ready", createPyProc); -// 5.4.1 change: We call exitPyreProc when all windows are killed so it has time to kill the process before closing - -/************************************************************* - * Main app window - *************************************************************/ - -let mainWindow: BrowserWindow; let user_restart_confirmed = false; let updatechecked = false; +let hasBeenOpened = false; + function initialize() { makeSingleInstance(); function createWindow() { - mainWindow.webContents.setWindowOpenHandler(({ url }) => { + globals.mainWindow.webContents.setWindowOpenHandler(({ url }) => { shell.openExternal(url); return { action: 'deny' }; }); - mainWindow.webContents.once("dom-ready", () => { + globals.mainWindow.webContents.once("dom-ready", () => { if (updatechecked == false) { autoUpdater.checkForUpdatesAndNotify(); } }); - mainWindow.on("close", async (e) => { + globals.mainWindow.once("close", async (e) => { globals.mainWindowReady = false @@ -267,32 +265,26 @@ function initialize() { } const quit_app = () => { - console.log("Quit app called"); - mainWindow.close(); - /// feedback form iframe prevents closing gracefully - /// so force close - if (!mainWindow.closed) { - mainWindow.destroy(); - } + globals.mainWindow.close(); + if (!globals.mainWindow.closed) globals.mainWindow.destroy() }; - app.on("ready", () => { + function onAppReady () { const promise = createPyProc(); // Listen after first load promise.then(() => { const chokidar = require('chokidar'); - let done = true chokidar.watch(path.join(__dirname, "../../pyflask"), { ignored: ['**/__pycache__/**'] }).on('all', async (event: string) => { - if (event === 'change' && done) { - done = false + if (event === 'change' && !globals.python.restart) { + globals.python.restart = true await exitPyProc(); setTimeout(async () => { await createPyProc(); - done = true + globals.python.restart = false }, 1000) } }); @@ -315,15 +307,17 @@ function initialize() { }, }; - mainWindow = new BrowserWindow(windowOptions); - main.enable(mainWindow.webContents); + globals.mainWindow = new BrowserWindow(windowOptions); + main.enable(globals.mainWindow.webContents); // HMR for renderer base on electron-vite cli. // Load the remote URL for development or the local html file for production. - if (is.dev && process.env['ELECTRON_RENDERER_URL']) mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) - else mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) - + if (is.dev && process.env['ELECTRON_RENDERER_URL']) globals.mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) + else globals.mainWindow.loadFile(path.join(__dirname, '../renderer/index.html')) + globals.mainWindow.once("closed", () => { + delete globals.mainWindow + }) const splash = new BrowserWindow({ width: 340, @@ -336,56 +330,78 @@ function initialize() { splash.loadFile(splashHTML) - // if main window is ready to show, then destroy the splash window and show up the main window - mainWindow.once("ready-to-show", () => { + globals.mainWindow.once("ready-to-show", () => { + setTimeout(function () { + + hasBeenOpened = true + splash.close(); - //mainWindow.maximize(); - mainWindow.show(); + globals.mainWindow.show(); createWindow(); - // run_pre_flight_checks(); + autoUpdater.checkForUpdatesAndNotify(); updatechecked = true; + globals.mainWindowReady = true - }, 1000); + + }, hasBeenOpened ? 100 : 1000); }); - }); + } - app.on("window-all-closed", async () => { - await exitPyProc(); - app.quit(); - }); - app.on("will-quit", () => { - app.quit(); - }); + if (app.isReady()) onAppReady() + else app.on("ready", onAppReady) +} + +function onFileOpened(_, path: string) { + restoreWindow() || initialize(); // Ensure the application is properly visible + onWindowReady((win) => win.webContents.send('fileOpened', path)) +} + +if (isWindows && process.argv.length >= 2) { + const openFilePath = process.argv[1]; + if (openFilePath !== "") onFileOpened(null, openFilePath) } // Make this app a single instance app. -const gotTheLock = app.requestSingleInstanceLock(); + +function restoreWindow(){ + if (globals.mainWindow) { + if (globals.mainWindow.isMinimized()) globals.mainWindow.restore(); + globals.mainWindow.focus(); + } + + return globals.mainWindow +} function makeSingleInstance() { if (process.mas) return; - if (!gotTheLock) { - app.quit(); - } else { - app.on("second-instance", () => { - if (mainWindow) { - if (mainWindow.isMinimized()) mainWindow.restore(); - mainWindow.focus(); - } - }); - } + if (!app.requestSingleInstanceLock()) app.quit(); + else app.on("second-instance", () => restoreWindow()); } - initialize(); +app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) initialize() +}) + +app.on("window-all-closed", async () => { + if (process.platform != 'darwin') { + await exitPyProc(); + app.quit(); + } +}); + +app.on("will-quit", () => app.quit()); +app.on("open-file", onFileOpened) + ipcMain.on("resize-window", (event, dir) => { - var x = mainWindow.getSize()[0]; - var y = mainWindow.getSize()[1]; + var x = globals.mainWindow.getSize()[0]; + var y = globals.mainWindow.getSize()[1]; if (dir === "up") { x = x + 1; y = y + 1; @@ -393,7 +409,7 @@ ipcMain.on("resize-window", (event, dir) => { x = x - 1; y = y - 1; } - mainWindow.setSize(x, y); + globals.mainWindow.setSize(x, y); }); autoUpdater.on("update-available", () => { diff --git a/src/renderer/src/electron/index.js b/src/renderer/src/electron/index.js index 6d786d524..4f9b30865 100644 --- a/src/renderer/src/electron/index.js +++ b/src/renderer/src/electron/index.js @@ -21,6 +21,10 @@ if (isElectron) { remote = require("@electron/remote"); app = remote.app; + electron.ipcRenderer.on("fileOpened", (info, ...args) => { + console.log("File opened!", ...args); + }); + ["log", "warn", "error"].forEach((method) => electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) ); diff --git a/src/renderer/src/index.ts b/src/renderer/src/index.ts index 1ce8be251..f00643058 100644 --- a/src/renderer/src/index.ts +++ b/src/renderer/src/index.ts @@ -100,6 +100,7 @@ const serverIsLiveStartup = async () => { return echoResponse === "server ready" ? true : false; }; +let openPythonStatusNotyf: undefined | any; async function pythonServerOpened() { @@ -109,7 +110,9 @@ async function pythonServerOpened() { // Update server status and throw a notification statusBar.items[2].status = true - notyf.open({ + + if (openPythonStatusNotyf) notyf.dismiss(openPythonStatusNotyf) + openPythonStatusNotyf = notyf.open({ type: "success", message: "Backend services are available", }); @@ -145,6 +148,14 @@ if (isElectron) { ipcRenderer.on("python.open", pythonServerOpened); ipcRenderer.on("python.closed", (_, message) => pythonServerClosed(message)); + ipcRenderer.on("python.restart", () => { + statusBar.items[2].status = undefined + if (openPythonStatusNotyf) notyf.dismiss(openPythonStatusNotyf) + openPythonStatusNotyf = notyf.open({ + type: "warning", + message: "Backend services are restarting...", + }) + }); // Check for update and show the pop up box ipcRenderer.on("update_available", () => { diff --git a/src/renderer/src/progress.js b/src/renderer/src/progress.js index 6e9930b65..5b5f27223 100644 --- a/src/renderer/src/progress.js +++ b/src/renderer/src/progress.js @@ -3,6 +3,7 @@ import Swal from "sweetalert2"; import { guidedProgressFilePath, reloadPageToHome, isStorybook, appDirectory } from "./dependencies/globals.js"; import { fs } from "./electron/index.js"; import { joinPath, runOnLoad } from "./globals.js"; +import { merge } from "./stories/pages/utils.js"; class GlobalAppConfig { path = `${appDirectory}/config.json`; @@ -48,32 +49,70 @@ export const update = (newDatasetName, previousDatasetName) => { } else throw new Error("No previous dataset name provided"); }; +export const getCurrentProjectName = () => { + const params = new URLSearchParams(location.search); + return params.get("project"); +}; + +export const updateAppProgress = ( + pageId, + dataOrProjectName = {}, + projectName = typeof dataOrProjectName === "string" ? dataOrProjectName : undefined +) => { + const transitionOffPipeline = pageId && pageId.split("/")[0] !== "conversion"; + + if (transitionOffPipeline) { + return; // Only save last page if within the conversion workflow + } + + if (projectName) { + const params = new URLSearchParams(location.search); + params.set("project", projectName); + + // Update browser history state + const value = `${location.pathname}?${params}`; + if (history.state) history.state.project = dataOrProjectName; + window.history.pushState(history.state, null, value); + } + + // Is a project name + if (dataOrProjectName === projectName) updateFile(dataOrProjectName, (data) => (data["page-before-exit"] = pageId)); + // Is a data object + else dataOrProjectName["page-before-exit"] = pageId; +}; + export const save = (page, overrides = {}) => { - const globalState = page.info.globalState; - let guidedProgressFileName = overrides.globalState?.project?.name ?? globalState.project?.name; + const globalState = merge(overrides, page.info.globalState); // Merge the overrides into the actual global state + + let guidedProgressFileName = globalState.project?.name; //return if guidedProgressFileName is not a string greater than 0 if (typeof guidedProgressFileName !== "string" || guidedProgressFileName.length === 0) return; - const params = new URLSearchParams(location.search); - params.set("project", guidedProgressFileName); + updateFile(guidedProgressFileName, () => { + updateAppProgress(page.info.id, globalState, guidedProgressFileName); // Will automatically set last updated time + return globalState; + }); +}; + +//Destination: HOMEDIR/NWB_GUIDE/pipelines +export const updateFile = (projectName, callback) => { + let data = get(projectName); - // Update browser history state - const value = `${location.pathname}?${params}`; - if (history.state) history.state.project = guidedProgressFileName; - window.history.pushState(history.state, null, value); + if (callback) { + const result = callback(data); + if (result && typeof result === "object") data = result; + } - //Destination: HOMEDIR/NWB_GUIDE/pipelines - globalState["last-modified"] = new Date(); - globalState["page-before-exit"] = overrides.id ?? page.info.id; + data["last-modified"] = new Date(); // Always update the last modified time - var guidedFilePath = joinPath(guidedProgressFilePath, guidedProgressFileName + ".json"); + var guidedFilePath = joinPath(guidedProgressFilePath, projectName + ".json"); // Save the file through the available mechanisms if (fs) { if (!fs.existsSync(guidedProgressFilePath)) fs.mkdirSync(guidedProgressFilePath, { recursive: true }); //create progress folder if one does not exist - fs.writeFileSync(guidedFilePath, JSON.stringify(globalState, null, 2)); - } else localStorage.setItem(guidedFilePath, JSON.stringify(globalState)); + fs.writeFileSync(guidedFilePath, JSON.stringify(data, null, 2)); + } else localStorage.setItem(guidedFilePath, JSON.stringify(data)); }; export const getEntries = () => { @@ -110,7 +149,9 @@ export const get = (name) => { } let progressFilePath = joinPath(guidedProgressFilePath, name + ".json"); - return JSON.parse(fs ? fs.readFileSync(progressFilePath) : localStorage.getItem(progressFilePath)); + + const exists = fs ? fs.existsSync(progressFilePath) : localStorage.getItem(progressFilePath) !== null; + return exists ? JSON.parse(fs ? fs.readFileSync(progressFilePath) : localStorage.getItem(progressFilePath)) : {}; }; export function resume(name) { diff --git a/src/renderer/src/stories/BasicTable.js b/src/renderer/src/stories/BasicTable.js index 47f19082e..1cad4503d 100644 --- a/src/renderer/src/stories/BasicTable.js +++ b/src/renderer/src/stories/BasicTable.js @@ -330,6 +330,8 @@ export class BasicTable extends LitElement { key in this.schema.properties ? (latest[key] = value) : "" ); // Only include data from schema }); + + this.onUpdate(null, null, value); // Update the whole table } // Render Code diff --git a/src/renderer/src/stories/Dashboard.js b/src/renderer/src/stories/Dashboard.js index ecb82ca9b..3d87174e3 100644 --- a/src/renderer/src/stories/Dashboard.js +++ b/src/renderer/src/stories/Dashboard.js @@ -31,6 +31,7 @@ import "../../../../node_modules/@sweetalert2/theme-bulma/bulma.css"; import "../../assets/css/guided.css"; import isElectron from "../electron/check.js"; import { isStorybook, reloadPageToHome } from "../dependencies/globals.js"; +import { getCurrentProjectName, updateAppProgress } from "../progress.js"; // import "https://jsuites.net/v4/jsuites.js" // import "https://bossanova.uk/jspreadsheet/v4/jexcel.js" @@ -64,7 +65,7 @@ export class Dashboard extends LitElement { name: { type: String, reflect: true }, logo: { type: String, reflect: true }, activePage: { type: String, reflect: true }, - globalState: { type: Object }, + globalState: { type: Object, reflect: true }, }; } @@ -96,7 +97,9 @@ export class Dashboard extends LitElement { this.sidebar.onClick = (_, value) => this.setAttribute("activePage", value.info.id); this.subSidebar = new NavigationSidebar(); - this.subSidebar.onClick = (id) => this.setAttribute("activePage", id); + this.subSidebar.onClick = async (id) => { + this.#active.to(id); + }; this.pages = props.pages ?? {}; this.name = props.name; @@ -138,7 +141,10 @@ export class Dashboard extends LitElement { else if (key === "renderNameInSidebar") this.sidebar.renderName = latest === "true" || latest === true; else if (key === "pages") this.#updated(latest); else if (key.toLowerCase() === "activepage") { - if (this.#active && this.#active.info.parent && this.#active.info.section) this.#active.save(); // Always properly saves the page + if (this.#active && this.#active.info.parent && this.#active.info.section) { + const currentProject = getCurrentProjectName(); + if (currentProject) updateAppProgress(latest, currentProject); + } while (latest && !this.pagesById[latest]) latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page @@ -146,6 +152,9 @@ export class Dashboard extends LitElement { this.sidebar.initialize = false; this.#activatePage(latest); return; + } else if (key.toLowerCase() === "globalstate" && this.#active) { + this.#active.info.globalState = JSON.parse(latest); + this.#active.requestUpdate(); } } diff --git a/src/renderer/src/stories/FileSystemSelector.js b/src/renderer/src/stories/FileSystemSelector.js index cc0e2b4c3..06d397672 100644 --- a/src/renderer/src/stories/FileSystemSelector.js +++ b/src/renderer/src/stories/FileSystemSelector.js @@ -137,4 +137,4 @@ export class FilesystemSelector extends LitElement { } } -customElements.get("nwb-filesystem-selector") || customElements.define("nwb-filesystem-selector", FilesystemSelector); +customElements.get("filesystem-selector") || customElements.define("filesystem-selector", FilesystemSelector); diff --git a/src/renderer/src/stories/InstanceManager.js b/src/renderer/src/stories/InstanceManager.js index c54a5f6f9..328882416 100644 --- a/src/renderer/src/stories/InstanceManager.js +++ b/src/renderer/src/stories/InstanceManager.js @@ -75,6 +75,10 @@ export class InstanceManager extends LitElement { height: 100%; } + #instance-display > div { + height: 100%; + } + #content { overflow: hidden; display: flex; diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index d44c065b7..4ecd4447f 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -143,17 +143,6 @@ pre { padding-top: 4px; color: dimgray !important; } - - input[type=number].hideStep::-webkit-outer-spin-button, - input[type=number].hideStep::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; - } - - /* Firefox */ - input[type=number].hideStep { - -moz-appearance: textfield; - } `; document.addEventListener("dragover", (e) => { @@ -217,10 +206,12 @@ export class JSONSchemaForm extends LitElement { this.conditionalRequirements = props.conditionalRequirements ?? []; // NOTE: We assume properties only belong to one conditional requirement group this.validateEmptyValues = props.validateEmptyValues ?? true; + if (props.onInvalid) this.onInvalid = props.onInvalid; if (props.validateOnChange) this.validateOnChange = props.validateOnChange; if (props.onThrow) this.onThrow = props.onThrow; if (props.onLoaded) this.onLoaded = props.onLoaded; + if (props.onUpdate) this.onUpdate = props.onUpdate; if (props.renderTable) this.renderTable = props.renderTable; if (props.onStatusChange) this.onStatusChange = props.onStatusChange; @@ -248,6 +239,13 @@ export class JSONSchemaForm extends LitElement { return form.getForm(path.slice(1)); }; + getInput = (path) => { + if (typeof path === "string") path = path.split("."); + const container = this.shadowRoot.querySelector(`#${path.join("-")}`); + if (!container) return; + return container.querySelector("jsonschema-input"); + }; + #requirements = {}; attributeChangedCallback(changedProperties, oldValue, newValue) { @@ -257,11 +255,15 @@ export class JSONSchemaForm extends LitElement { // Track resolved values for the form (data only) updateData(fullPath, value) { - this.onUpdate(fullPath, value); const path = [...fullPath]; const name = path.pop(); - const resultParent = path.reduce((acc, key) => acc[key], this.results); - const resolvedParent = path.reduce((acc, key) => acc[key], this.resolved); + + const reducer = (acc, key) => (key in acc ? acc[key] : (acc[key] = {})); // NOTE: Create nested objects if required to set a new path + + const resultParent = path.reduce(reducer, this.results); + const resolvedParent = path.reduce(reducer, this.resolved); + + if (resolvedParent[name] !== value) this.onUpdate(fullPath, value); // Ensure the value has actually changed if (!value) { delete resultParent[name]; @@ -308,7 +310,7 @@ export class JSONSchemaForm extends LitElement { validate = async () => { // Check if any required inputs are missing - const invalidInputs = await this.#validateRequirements(this.resolved, this.#requirements); // get missing required paths + const invalidInputs = await this.#validateRequirements(); // get missing required paths const isValid = !invalidInputs.length; // Print out a detailed error message if any inputs are missing @@ -366,7 +368,7 @@ export class JSONSchemaForm extends LitElement { const name = path.pop(); const element = this.shadowRoot .querySelector(`#${fullPath.join("-")}`) - .querySelector("nwb-jsonschema-input") + .querySelector("jsonschema-input") .shadowRoot.querySelector(".guided--input"); const isValid = await this.triggerValidation(name, element, path, false); if (!isValid) return true; @@ -388,7 +390,7 @@ export class JSONSchemaForm extends LitElement { return resolved; } - #renderInteractiveElement = (name, info, parent, required, path = []) => { + #renderInteractiveElement = (name, info, required, path = []) => { let isRequired = required[name]; const fullPath = [...path, name]; @@ -416,14 +418,14 @@ export class JSONSchemaForm extends LitElement { info, parent, path: fullPath, - value: parent[name], + value, form: this, required: isRequired, }); interactiveInput.updated = () => { let input = interactiveInput.shadowRoot.querySelector(".schema-input"); - if (!input) input = interactiveInput.shadowRoot.querySelector("nwb-filesystem-selector"); + if (!input) input = interactiveInput.shadowRoot.querySelector("filesystem-selector"); if (input) { if (this.validateEmptyValues || (input.value ?? input.checked) !== "") @@ -433,9 +435,9 @@ export class JSONSchemaForm extends LitElement { // this.validateEmptyValues ? undefined : (el) => (el.value ?? el.checked) !== "" - // const possibleInputs = Array.from(this.shadowRoot.querySelectorAll("nwb-jsonschema-input")).map(input => input.children) + // const possibleInputs = Array.from(this.shadowRoot.querySelectorAll("jsonschema-input")).map(input => input.children) // const inputs = possibleInputs.filter(el => el instanceof HTMLElement); - // const fileInputs = Array.from(this.shadowRoot.querySelectorAll("nwb-filesystem-selector") ?? []); + // const fileInputs = Array.from(this.shadowRoot.querySelectorAll("filesystem-selector") ?? []); // const allInputs = [...inputs, ...fileInputs]; // const filtered = filter ? allInputs.filter(filter) : allInputs; // filtered.forEach((input) => input.dispatchEvent(new Event("change"))); @@ -477,17 +479,18 @@ export class JSONSchemaForm extends LitElement { } }; - #validateRequirements = async (results, requirements, parent) => { + #validateRequirements = async (resolved = this.resolved, requirements = this.#requirements, parentPath) => { let invalid = []; for (let name in requirements) { let isRequired = requirements[name]; if (typeof isRequired === "function") isRequired = await isRequired.call(this.resolved); if (isRequired) { - let path = parent ? `${parent}-${name}` : name; + let path = parentPath ? `${parentPath}-${name}` : name; + if (typeof isRequired === "object" && !Array.isArray(isRequired)) - invalid.push(...(await this.#validateRequirements(results[name], isRequired, path))); - else if (!results[name]) invalid.push(path); + invalid.push(...(await this.#validateRequirements(resolved[name], isRequired, path))); + else if (!resolved[name]) invalid.push(path); } } @@ -497,6 +500,7 @@ export class JSONSchemaForm extends LitElement { // Checks missing required properties and throws an error if any are found onInvalid = () => {}; onLoaded = () => {}; + onUpdate = () => {}; #deleteExtraneousResults = (results, schema) => { for (let name in results) { @@ -733,7 +737,7 @@ export class JSONSchemaForm extends LitElement { const linkedProperties = info.properties.map((path) => { const pathCopy = [...path].slice((this.#base ?? []).length); const name = pathCopy.pop(); - return this.#renderInteractiveElement(name, schema.properties[name], results, required, pathCopy); + return this.#renderInteractiveElement(name, schema.properties[name], required, pathCopy); }); return html`
diff --git a/src/renderer/src/stories/pages/FormPage.js b/src/renderer/src/stories/pages/FormPage.js
index 8a21d0f46..1cd9ddc28 100644
--- a/src/renderer/src/stories/pages/FormPage.js
+++ b/src/renderer/src/stories/pages/FormPage.js
@@ -3,6 +3,7 @@ import { JSONSchemaForm } from "../JSONSchemaForm.js";
import { Page } from "./Page.js";
import { validateOnChange } from "../../validation/index.js";
import { onThrow } from "../../errors";
+import { merge } from "./utils.js";
export function schemaToPages(schema, globalStatePath, options, transformationCallback = (info) => info) {
return Object.entries(schema.properties)
@@ -46,11 +47,22 @@ export class GuidedFormPage extends Page {
if (!this.info.formOptions.results) this.info.formOptions.results = {};
}
+ beforeSave = () => {
+ // Merge results before saving
+ if (this.info.globalStatePath) {
+ const parent = this.info.globalStatePath.reduce(
+ (acc, key) => acc[key] ?? (acc[key] = {}),
+ this.info.globalState
+ );
+ parent[this.info.key] = this.localState[this.info.key];
+ }
+ };
+
footer = {
onNext: async () => {
- this.save();
- await this.form.validate();
- this.onTransition(1);
+ await this.save(); // Save in case validation fails
+ await this.form.validate(); // Validate the results of the form
+ this.to(1);
},
};
@@ -59,11 +71,13 @@ export class GuidedFormPage extends Page {
const temp = this.info.globalStatePath
? this.info.globalStatePath.reduce((acc, key) => acc[key] ?? (acc[key] = {}), this.info.globalState)
: {};
- const results = { [key]: temp[key] ?? (temp[key] = {}) };
+
+ const results = (this.localState = merge({ [key]: temp[key] ?? (temp[key] = {}) }, {})); // Keep a local copy of the results
const form = (this.form = new JSONSchemaForm({
...this.info.formOptions,
results,
+ onUpdate: () => (this.unsavedUpdates = true),
validateOnChange,
onThrow,
}));
diff --git a/src/renderer/src/stories/pages/GuidedMode.stories.js b/src/renderer/src/stories/pages/GuidedMode.stories.js
deleted file mode 100644
index 4ee9acf89..000000000
--- a/src/renderer/src/stories/pages/GuidedMode.stories.js
+++ /dev/null
@@ -1,150 +0,0 @@
-import { dashboard } from "../../pages.js";
-import nwbBaseSchema from "../../../../../schemas/base-metadata.schema";
-import exephysExampleSchema from "../../../../../schemas/json/ecephys_metadata_schema_example.json";
-
-const options = Object.keys(dashboard.pagesById).filter((k) => k.includes("conversion"));
-
-export default {
- title: "Pages/Guided Mode",
- parameters: {
- chromatic: { disableSnapshot: false },
- },
- argTypes: {
- activePage: {
- options,
- control: { type: "select" },
- },
- },
-};
-
-nwbBaseSchema.properties.Ecephys = exephysExampleSchema;
-
-const globalState = {
- project: {
- name: "test",
- NWBFile: {
- lab: "My Lab",
- },
- Subject: {
- species: "Mus musculus",
- },
- },
- subjects: {
- subject_id: {},
- },
- results: {
- subject_id: {
- session_id: {
- metadata: {},
- source_data: {},
- },
- },
- },
- interfaces: {
- neuropixel: "SpikeGLXRecordingInterface",
- },
- schema: {
- source_data: {
- properties: {
- neuropixel: {
- type: "object",
- properties: {
- file_path: {
- type: "string",
- description: "Enter the path to the source data file.",
- format: "file",
- },
- },
- required: ["file_path"],
- },
- },
- },
- metadata: {
- subject_id: {
- session_id: nwbBaseSchema,
- },
- },
- },
-};
-
-const Template = (args = {}) => {
- for (let k in args) dashboard[k] = args[k];
- return dashboard;
-};
-
-export const Home = Template.bind({});
-Home.args = {
- activePage: "conversion",
-};
-
-export const Start = Template.bind({});
-Start.args = {
- activePage: "conversion/start",
-};
-
-export const NewDataset = Template.bind({});
-NewDataset.args = {
- activePage: "conversion/details",
-};
-
-export const Structure = Template.bind({});
-Structure.args = {
- activePage: "conversion/structure",
-};
-
-export const Locate = Template.bind({});
-Locate.args = {
- activePage: "conversion/locate",
-};
-
-export const Subjects = Template.bind({});
-Subjects.args = {
- activePage: "conversion/subjects",
-};
-
-export const SourceData = Template.bind({});
-SourceData.args = {
- activePage: "conversion/sourcedata",
-};
-
-export const Metadata = Template.bind({});
-Metadata.args = {
- activePage: "conversion/metadata",
-};
-
-export const ConversionOptions = Template.bind({});
-ConversionOptions.args = {
- activePage: "conversion/options",
-};
-
-export const StubPreview = Template.bind({});
-StubPreview.args = {
- activePage: "conversion/preview",
-};
-
-export const Upload = Template.bind({});
-Upload.args = {
- activePage: "conversion/upload",
-};
-
-export const Results = Template.bind({});
-Results.args = {
- activePage: "conversion/review",
-};
-
-const statefulPages = [
- Home,
- Start,
- NewDataset,
- Structure,
- Locate,
- Subjects,
- SourceData,
- Metadata,
- ConversionOptions,
- StubPreview,
- Upload,
- Results,
-];
-
-statefulPages.forEach((page) => (page.args.globalState = globalState));
diff --git a/src/renderer/src/stories/pages/Page.js b/src/renderer/src/stories/pages/Page.js
index 407d2390b..d5376f502 100644
--- a/src/renderer/src/stories/pages/Page.js
+++ b/src/renderer/src/stories/pages/Page.js
@@ -6,6 +6,7 @@ import { merge, randomizeElements, mapSessions } from "./utils.js";
import { ProgressBar } from "../ProgressBar";
import { resolveResults } from "./guided-mode/data/utils.js";
+import Swal from "sweetalert2";
export class Page extends LitElement {
// static get styles() {
@@ -59,16 +60,47 @@ export class Page extends LitElement {
this.#notifications.push(note);
};
+ to = async (transition) => {
+ this.beforeTransition();
+
+ // Otherwise note unsaved updates if present
+ if (this.unsavedUpdates) {
+ if (transition === 1) await this.save(); // Save before a single forward transition
+ else {
+ Swal.fire({
+ title: "You have unsaved data on this page.",
+ text: "Would you like to save your changes?",
+ icon: "warning",
+ showCancelButton: true,
+ confirmButtonColor: "#3085d6",
+ confirmButtonText: "Save and Continue",
+ cancelButtonText: "Ignore Changes",
+ }).then(async (result) => {
+ if (result && result.isConfirmed) await this.save();
+ this.onTransition(transition);
+ });
+
+ return;
+ }
+ }
+
+ this.onTransition(transition);
+ };
+
onTransition = () => {}; // User-defined function
updatePages = () => {}; // User-defined function
+ beforeSave = () => {}; // User-defined function
+ beforeTransition = () => {}; // User-defined function
- save = (overrides) => save(this, overrides);
+ save = async (overrides, runBeforeSave = true) => {
+ if (runBeforeSave) await this.beforeSave();
+ save(this, overrides);
+ this.unsavedUpdates = false;
+ };
load = (datasetNameToResume = new URLSearchParams(window.location.search).get("project")) =>
(this.info.globalState = get(datasetNameToResume));
- merge = merge;
-
addSession({ subject, session, info }) {
if (!this.info.globalState.results[subject]) this.info.globalState.results[subject] = {};
if (this.info.globalState.results[subject][session])
@@ -83,7 +115,7 @@ export class Page extends LitElement {
delete this.info.globalState.results[subject][session];
}
- mapSessions = (callback) => mapSessions(callback, this.info.globalState);
+ mapSessions = (callback, data = this.info.globalState) => mapSessions(callback, data);
async runConversions(conversionOptions = {}, toRun, options = {}) {
let original = toRun;
@@ -94,7 +126,7 @@ export class Page extends LitElement {
else if (typeof original === "string") toRun = toRun.filter(({ subject }) => subject === original);
else if (typeof original === "function") toRun = toRun.filter(original);
- let results = [];
+ const results = {};
const popup = await openProgressSwal({ title: `Running conversion`, ...options });
@@ -115,15 +147,15 @@ export class Page extends LitElement {
elements.progress.value = { b: completed, tsize: toRun.length };
for (let info of toRun) {
- const { subject, session } = info;
+ const { subject, session, globalState = this.info.globalState } = info;
const file = `sub-${subject}/sub-${subject}_ses-${session}.nwb`;
- const { conversion_output_folder, stub_output_folder, name } = this.info.globalState.project;
+ const { conversion_output_folder, stub_output_folder, name } = globalState.project;
// Resolve the correct session info from all of the metadata for this conversion
const sessionInfo = {
- ...this.info.globalState.results[subject][session],
- metadata: resolveResults(subject, session, this.info.globalState),
+ ...globalState.results[subject][session],
+ metadata: resolveResults(subject, session, globalState),
};
const result = await runConversion(
@@ -135,7 +167,7 @@ export class Page extends LitElement {
...sessionInfo, // source_data and metadata are passed in here
...conversionOptions, // Any additional conversion options override the defaults
- interfaces: this.info.globalState.interfaces,
+ interfaces: globalState.interfaces,
},
{ swal: popup, ...options }
).catch((e) => {
@@ -150,7 +182,8 @@ export class Page extends LitElement {
elements.progress.value = progressInfo;
}
- results.push(result);
+ const subRef = results[subject] ?? (results[subject] = {});
+ subRef[session] = result;
}
popup.close();
@@ -165,6 +198,13 @@ export class Page extends LitElement {
this.updatePages();
};
+ unsavedUpdates = false; // Track unsaved updates
+
+ // NOTE: Make sure you call this explicitly if a child class overwrites this AND data is updated
+ updated() {
+ this.unsavedUpdates = false;
+ }
+
render() {
return html`