diff --git a/.github/workflows/daily_tests.yml b/.github/workflows/daily_tests.yml index 32b8b1edd5..fcae04f533 100644 --- a/.github/workflows/daily_tests.yml +++ b/.github/workflows/daily_tests.yml @@ -39,10 +39,7 @@ jobs: NotifyOnAnyFailure: runs-on: ubuntu-latest needs: [DevTests, LiveServices, BuildTests, ExampleDataCache, ExampleDataTests] - if: | - ${{ - always() && (needs.DevTests.result == 'failure' || needs.LiveServices.result == 'failure' || needs.BuildTests.result == 'failure' || needs.ExampleDataCache.result == 'failure' || needs.ExampleDataTests.result == 'failure') - }} + if: always() && (${{ needs.DevTests.result }} == 'failure' || ${{ needs.LiveServices.result }} == 'failure' || ${{ needs.BuildTests.result }} == 'failure' || ${{ needs.ExampleDataCache.result }} == 'failure' || ${{ needs.ExampleDataTests.result }} == 'failure') steps: - name: Printout which failed run: | @@ -51,6 +48,10 @@ jobs: echo "BuildTests: ${{ needs.BuildTests.result }}" echo "ExampleDataCache: ${{ needs.ExampleDataCache.result }}" echo "ExampleDataTests: ${{ needs.ExampleDataTests.result }}" + - name: debugging + run: export test=(${{ needs.DevTests.result }} == 'failure' || ${{ needs.LiveServices.result }} == 'failure' || ${{ needs.BuildTests.result }} == 'failure' || ${{ needs.ExampleDataCache.result }} == 'failure' || ${{ needs.ExampleDataTests.result }} == 'failure') + - name: debugging print + run: echo $test #- name: Printout logic trigger # run: ${{ needs.DevTests.result }} == 'failure' || ${{ needs.LiveServices.result }} == 'failure' || ${{ needs.BuildTests.result }} == 'failure' || ${{ needs.ExampleDataCache.result }} == 'failure' || ${{ needs.ExampleDataTests.result }} == 'failure' - uses: dawidd6/action-send-mail@v3 diff --git a/src/electron/frontend/core/components/FileSystemSelector.js b/src/electron/frontend/core/components/FileSystemSelector.js index da6f4925e8..d9d444d8e4 100644 --- a/src/electron/frontend/core/components/FileSystemSelector.js +++ b/src/electron/frontend/core/components/FileSystemSelector.js @@ -280,9 +280,11 @@ export class FilesystemSelector extends LitElement { >` : ""}`} -
- ${this.value ? html`
this.#handleFiles()}>${unsafeSVG(restartSVG)}
` : ""} -
+
+ ${this.value + ? html`
this.#handleFiles()}>${unsafeSVG(restartSVG)}
` + : ""} +
${this.multiple && isArray && this.value.length > 1 ? new List({ @@ -293,7 +295,6 @@ export class FilesystemSelector extends LitElement { }, }) : ""} - ${isMultipleTypes ? html`
diff --git a/src/electron/frontend/core/components/InspectorList.js b/src/electron/frontend/core/components/InspectorList.js index 790124124b..5af3ac696c 100644 --- a/src/electron/frontend/core/components/InspectorList.js +++ b/src/electron/frontend/core/components/InspectorList.js @@ -4,13 +4,12 @@ import { getMessageType, isErrorImportance } from "../validation"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { header } from '../../utils/text' +import { header } from "../../utils/text"; const sortAlphabeticallyWithNumberedStrings = (a, b) => { if (a === b) return 0; - return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }); -} - + return a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); +}; const sortList = (items) => { return items @@ -23,19 +22,19 @@ const sortList = (items) => { else if (lowA) return 1; else return -1; }) - + .sort((a, b) => { const aCritical = isErrorImportance.includes(a.importance); const bCritical = isErrorImportance.includes(b.importance); if (aCritical && bCritical) return 0; else if (aCritical) return -1; else return 1; - }) + }); }; const aggregateMessages = (items) => { let messages = {}; - console.log(items) + console.log(items); items.forEach((item) => { const copy = { ...item }; delete copy.file_path; diff --git a/src/electron/frontend/core/components/InstanceListItem.ts b/src/electron/frontend/core/components/InstanceListItem.ts index 61ebc2afec..f2c8b5896e 100644 --- a/src/electron/frontend/core/components/InstanceListItem.ts +++ b/src/electron/frontend/core/components/InstanceListItem.ts @@ -7,7 +7,6 @@ export class InstanceListItem extends LitElement { declare label: string declare status: string declare selected: boolean - declare onRemoved?: Function static get styles() { return css` @@ -120,7 +119,7 @@ export class InstanceListItem extends LitElement { } } - constructor({ label, status, selected, onRemoved, id, ...metadata } = { + constructor({ label, status, selected, id, ...metadata } = { label: "", status: "", selected: false @@ -131,7 +130,6 @@ export class InstanceListItem extends LitElement { this.status = status; this.selected = selected; this.metadata = metadata - if (this.onRemoved) this.onRemoved = onRemoved; } #onClick () { @@ -154,18 +152,6 @@ export class InstanceListItem extends LitElement { >${this.label}
- ${this.onRemoved - ? html`x` - : ""} ` } diff --git a/src/electron/frontend/core/components/InstanceManager.js b/src/electron/frontend/core/components/InstanceManager.js index ad0d8a1e6e..cfb4e1f86d 100644 --- a/src/electron/frontend/core/components/InstanceManager.js +++ b/src/electron/frontend/core/components/InstanceManager.js @@ -1,6 +1,6 @@ import { LitElement, css, html } from "lit"; import "./Button"; -import { notify } from "../dependencies"; +import { notify } from "../notifications"; import { Accordion } from "./Accordion"; import { InstanceListItem } from "./InstanceListItem"; import { checkStatus } from "../validation"; @@ -142,7 +142,6 @@ export class InstanceManager extends LitElement { this.header = props.header; this.instanceType = props.instanceType ?? "Instance"; if (props.onAdded) this.onAdded = props.onAdded; - if (props.onRemoved) this.onRemoved = props.onRemoved; if (props.onDisplay) this.onDisplay = props.onDisplay; this.controls = props.controls ?? []; } @@ -157,8 +156,12 @@ export class InstanceManager extends LitElement { } else return content; }; + getInstance(id) { + return this.#items.find((item) => item.id === id); + } + updateState = (id, state) => { - const item = this.#items.find((i) => i.id === id); + const item = this.getInstance(id); item.status = state; @@ -178,7 +181,6 @@ export class InstanceManager extends LitElement { }; // onAdded = () => {} - // onRemoved = () => {} toggleInput = (force) => { const newInfoDiv = this.shadowRoot.querySelector("#new-info"); @@ -248,34 +250,6 @@ export class InstanceManager extends LitElement { #items = []; #info = {}; - #onRemoved(ev) { - const parent = ev.target.parentNode; - const name = parent.getAttribute("data-instance"); - const ogPath = name.split("/"); - const path = [...ogPath]; - let target = toRender; - const key = path.pop(); - target = path.reduce((acc, cur) => acc[cur], target); - this.onRemoved(target[key], ogPath); - delete target[key]; - - if (parent.hasAttribute("selected")) { - const previous = parent.previousElementSibling?.getAttribute("data-instance"); - if (previous) this.#selected = previous; - else { - const next = parent.nextElementSibling?.getAttribute("data-instance"); - if (next) this.#selected = next; - else this.#selected = undefined; - } - } - - // parent.remove() - // const instance = this.shadowRoot.querySelector(`div[data-instance="${name}"]`) - // instance.remove() - - this.requestUpdate(); - } - #hideAll(chosenInstanceElement) { Array.from(this.shadowRoot.querySelectorAll("div[data-instance]")).forEach((instanceElement) => { if (instanceElement !== chosenInstanceElement) instanceElement.hidden = true; @@ -332,7 +306,6 @@ export class InstanceManager extends LitElement { id: key, label: key.split("/").pop(), selected: key === this.#selected, - onRemoved: this.#onRemoved.bind(this), ...info, }; diff --git a/src/electron/frontend/core/components/JSONSchemaInput.js b/src/electron/frontend/core/components/JSONSchemaInput.js index f9966222f6..91ec60014d 100644 --- a/src/electron/frontend/core/components/JSONSchemaInput.js +++ b/src/electron/frontend/core/components/JSONSchemaInput.js @@ -19,6 +19,7 @@ import { OptionalSection } from "./OptionalSection"; import { InspectorListItem } from "./InspectorList.js"; import { renderDateTime, resolveDateTime } from "./DateTimeSelector"; import { isObject } from "../../utils/typecheck"; +import { resolve } from "../../utils/promises"; const isDevelopment = !!import.meta.env; @@ -583,7 +584,7 @@ export class JSONSchemaInput extends LitElement { // onUpdate = () => {} // onValidate = () => {} - updateData(value, forceValidate = false) { + async updateData(value, forceValidate = false) { if (!forceValidate) { // Update the actual input element const inputElement = this.getElement(); @@ -603,9 +604,9 @@ export class JSONSchemaInput extends LitElement { const name = path.splice(-1)[0]; this.#updateData(fullPath, value); - this.#triggerValidation(name, path); // NOTE: Is asynchronous + const possiblePromise = this.#triggerValidation(name, path); - return true; + return resolve(possiblePromise, () => true); } getElement = () => this.shadowRoot.querySelector(".schema-input"); @@ -641,7 +642,7 @@ export class JSONSchemaInput extends LitElement { if (hooks.willTimeout !== false) this.#activateTimeoutValidation(name, path, hooks); }; - #triggerValidation = async (name, path) => { + #triggerValidation = (name, path) => { this.#clearTimeoutValidation(); return this.onValidate ? this.onValidate() diff --git a/src/electron/frontend/core/components/NWBFilePreview.js b/src/electron/frontend/core/components/NWBFilePreview.js index c5cc63cb23..43ead6138e 100644 --- a/src/electron/frontend/core/components/NWBFilePreview.js +++ b/src/electron/frontend/core/components/NWBFilePreview.js @@ -159,11 +159,7 @@ export class NWBFilePreview extends LitElement { { path: fileArr[0].info.file, ...options }, { title } ) // Inspect the first file - : await run( - "neuroconv/inspect", - { path, ...options }, - { title: title + "s" } - ); // Inspect the folder + : await run("neuroconv/inspect", { path, ...options }, { title: title + "s" }); // Inspect the folder const result = onlyFirstFile ? { diff --git a/src/electron/frontend/core/components/pages/Page.js b/src/electron/frontend/core/components/pages/Page.js index 886987dfbe..8cda7e1e36 100644 --- a/src/electron/frontend/core/components/pages/Page.js +++ b/src/electron/frontend/core/components/pages/Page.js @@ -2,7 +2,7 @@ import { LitElement, html } from "lit"; import { run } from "../../../utils/run"; import { get, save } from "../../progress/index.js"; -import { dismissNotification, notify } from "../../dependencies.js"; +import { dismissNotification, notify } from "../../notifications"; import { isStorybook } from "../../globals.js"; import { mapSessions, merge } from "../../../utils/data"; diff --git a/src/electron/frontend/core/components/pages/contact-us/Contact.js b/src/electron/frontend/core/components/pages/contact-us/Contact.js index 2b8995dae9..c2e35bbb5a 100644 --- a/src/electron/frontend/core/components/pages/contact-us/Contact.js +++ b/src/electron/frontend/core/components/pages/contact-us/Contact.js @@ -2,7 +2,7 @@ import { html } from "lit"; import { contact_lottie } from "../../../../assets/lotties/contact-us-lotties.js"; import { Page } from "../Page.js"; -import { startLottie } from "../../../dependencies.js"; +import { startLottie } from "../../../lotties"; export class ContactPage extends Page { header = { diff --git a/src/electron/frontend/core/components/pages/documentation/Documentation.js b/src/electron/frontend/core/components/pages/documentation/Documentation.js index 77ca419d45..8a3b62dbe2 100644 --- a/src/electron/frontend/core/components/pages/documentation/Documentation.js +++ b/src/electron/frontend/core/components/pages/documentation/Documentation.js @@ -2,7 +2,7 @@ import { html } from "lit"; import { docu_lottie } from "../../../../assets/lotties/documentation-lotties.js"; import { Page } from "../Page.js"; -import { startLottie } from "../../../dependencies.js"; +import { startLottie } from "../../../lotties"; import { Button } from "../../Button.js"; diff --git a/src/electron/frontend/core/components/pages/guided-mode/GuidedHome.js b/src/electron/frontend/core/components/pages/guided-mode/GuidedHome.js index a71dc03d6b..8352f49a90 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/GuidedHome.js +++ b/src/electron/frontend/core/components/pages/guided-mode/GuidedHome.js @@ -2,7 +2,7 @@ import { html } from "lit"; import { Page } from "../Page.js"; import { ProgressCard } from "./ProgressCard.js"; -import { startLottie } from "../../../dependencies.js"; +import { startLottie } from "../../../lotties"; import * as progress from "../../../progress/index.js"; import { newDataset } from "../../../../assets/lotties/index.js"; diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js index 3df88eb5f0..3cb9131854 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js +++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedInspectorPage.js @@ -134,29 +134,27 @@ export class GuidedInspectorPage extends Page { return html` ${until( (async () => { - this.report = inspector; const oneFile = fileArr.length <= 1; - const willRun = !this.report + const willRun = !this.report; const swalOpts = willRun ? await createProgressPopup({ title: oneFile ? title : `${title}s` }) : {}; const { close: closeProgressPopup } = swalOpts; if (oneFile) { - if (!this.report) { const result = await run( "neuroconv/inspect", { path: fileArr[0].info.file, ...options, request_id: swalOpts.id }, swalOpts - ).catch((error) => { - this.notify(error.message, "error"); - return null; - }) - .finally(() => closeProgressPopup()); - + ) + .catch((error) => { + this.notify(error.message, "error"); + return null; + }) + .finally(() => closeProgressPopup()); if (!result) return "Failed to generate inspector report."; @@ -182,7 +180,6 @@ export class GuidedInspectorPage extends Page { const path = getSharedPath(fileArr.map(({ info }) => info.file)); if (!this.report) { - const result = await run( "neuroconv/inspect", { path, ...options, request_id: swalOpts.id }, diff --git a/src/electron/frontend/core/components/pages/inspect/InspectPage.js b/src/electron/frontend/core/components/pages/inspect/InspectPage.js index e97605d83f..e711673b32 100644 --- a/src/electron/frontend/core/components/pages/inspect/InspectPage.js +++ b/src/electron/frontend/core/components/pages/inspect/InspectPage.js @@ -39,7 +39,7 @@ export class InspectPage extends Page { } ); - console.log(result) + console.log(result); closeProgressPopup(); diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index ae866c70f9..77746f42c0 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -12,7 +12,7 @@ import { Button } from "../../Button.js"; import { global, remove, save } from "../../../progress/index.js"; import { merge, setUndefinedIfNotDeclared } from "../../../../utils/data"; -import { notyf } from "../../../dependencies.js"; +import { notyf } from "../../../notifications"; import { homeDirectory, testDataFolderPath } from "../../../globals.js"; import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron"; diff --git a/src/electron/frontend/core/components/table/Cell.ts b/src/electron/frontend/core/components/table/Cell.ts index af458033bc..97854d716b 100644 --- a/src/electron/frontend/core/components/table/Cell.ts +++ b/src/electron/frontend/core/components/table/Cell.ts @@ -179,9 +179,8 @@ export class TableCell extends LitElement { if (!this.editable) return // Don't set value if not editable - if (this.input) this.input.set(value) // Ensure all operations are undoable + if (this.input) return this.input.set(value) // Ensure all operations are undoable else this.#value = value // Silently set value if not rendered yet - } #value diff --git a/src/electron/frontend/core/errors.ts b/src/electron/frontend/core/errors.ts index 8c64621d12..d60c6a5a42 100644 --- a/src/electron/frontend/core/errors.ts +++ b/src/electron/frontend/core/errors.ts @@ -1,2 +1,2 @@ -import { notify } from './dependencies' +import { notify } from './notifications' export const onThrow = (message: string, id?: string) => notify(id ? `[${id}]: ${message}` : message, "error", 7000); diff --git a/src/electron/frontend/core/index.ts b/src/electron/frontend/core/index.ts index 815094fc84..ef44860c34 100644 --- a/src/electron/frontend/core/index.ts +++ b/src/electron/frontend/core/index.ts @@ -9,7 +9,7 @@ import { Dashboard } from './components/Dashboard.js' import { notyf, notify -} from './dependencies.js' +} from './notifications' import Swal from 'sweetalert2' import { loadServerEvents, pythonServerOpened } from "./server/index.js"; diff --git a/src/electron/frontend/core/lotties.ts b/src/electron/frontend/core/lotties.ts new file mode 100644 index 0000000000..a29418ebd6 --- /dev/null +++ b/src/electron/frontend/core/lotties.ts @@ -0,0 +1,19 @@ +import lottie from "lottie-web"; + +import checkChromatic from "chromatic/isChromatic"; +export const isChromatic = checkChromatic(); + +export const startLottie = (lottieElement: HTMLElement, animationData: any) => { + lottieElement.innerHTML = ""; + const thisLottie = lottie.loadAnimation({ + container: lottieElement, + animationData, + renderer: "svg", + loop: !isChromatic, + autoplay: !isChromatic, + }); + + if (isChromatic) thisLottie.goToAndStop(thisLottie.getDuration(true) - 1, true); // Go to last frame + + return thisLottie; +}; diff --git a/src/electron/frontend/core/dependencies.js b/src/electron/frontend/core/notifications.ts similarity index 82% rename from src/electron/frontend/core/dependencies.js rename to src/electron/frontend/core/notifications.ts index 810456d112..261007df0c 100644 --- a/src/electron/frontend/core/dependencies.js +++ b/src/electron/frontend/core/notifications.ts @@ -1,24 +1,4 @@ import { Notyf } from "notyf"; -import checkChromatic from "chromatic/isChromatic"; -import lottie from "lottie-web"; - -// ---------- Lottie Helper ---------- -const isChromatic = checkChromatic(); - -export const startLottie = (lottieElement, animationData) => { - lottieElement.innerHTML = ""; - const thisLottie = lottie.loadAnimation({ - container: lottieElement, - animationData, - renderer: "svg", - loop: !isChromatic, - autoplay: !isChromatic, - }); - - if (isChromatic) thisLottie.goToAndStop(thisLottie.getDuration(true) - 1, true); // Go to last frame - - return thisLottie; -}; const longDuration = 20000; diff --git a/src/electron/frontend/core/server/index.ts b/src/electron/frontend/core/server/index.ts index 89edce13a7..4fbc898adc 100644 --- a/src/electron/frontend/core/server/index.ts +++ b/src/electron/frontend/core/server/index.ts @@ -5,7 +5,7 @@ import { isTestEnvironment } from '../globals.js' import { notyf, -} from '../dependencies.js' +} from '../notifications' import Swal from 'sweetalert2' diff --git a/src/electron/frontend/utils/popups.ts b/src/electron/frontend/utils/popups.ts index b89928d1dc..d7584704c8 100644 --- a/src/electron/frontend/utils/popups.ts +++ b/src/electron/frontend/utils/popups.ts @@ -40,7 +40,7 @@ export const createProgressPopup = async ( options.showCancelButton = true; options.customClass = { actions: "swal-conversion-actions" }; } - + await openProgressSwal(options, (result) => { if (!result.isConfirmed) cancelController.abort(); }); diff --git a/src/electron/frontend/utils/random.ts b/src/electron/frontend/utils/random.ts index 04fcbc60e2..12dec871cf 100644 --- a/src/electron/frontend/utils/random.ts +++ b/src/electron/frontend/utils/random.ts @@ -1,7 +1,13 @@ +const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; export const getRandomIndex = (count: number) => Math.floor(count * Math.random()); -export const getRandomString = () => Math.random().toString(36).substring(7); +// return random string always of len characters +export const getRandomString = (len = 7) => { + let result = ""; + for (let i = 0; i < len; i++) result += chars.charAt(Math.floor(Math.random() * chars.length)); + return result; +} export const getRandomSample = ( array: any[], @@ -18,4 +24,4 @@ export const getRandomSample = ( result.push(element); } return result; -}; +}; diff --git a/src/pyflask/manageNeuroconv/manage_neuroconv.py b/src/pyflask/manageNeuroconv/manage_neuroconv.py index eac97906a1..007491060a 100644 --- a/src/pyflask/manageNeuroconv/manage_neuroconv.py +++ b/src/pyflask/manageNeuroconv/manage_neuroconv.py @@ -1438,10 +1438,9 @@ def _inspect_all(url, config): from nwbinspector.utils import calculate_number_of_cpu from tqdm_publisher import TQDMProgressSubscriber - path = config.pop("path", None) - paths = [ path ] if path else config.pop("paths", []) + paths = [path] if path else config.pop("paths", []) nwbfile_paths = [] for path in paths: @@ -1450,7 +1449,7 @@ def _inspect_all(url, config): nwbfile_paths.append(posix_path) else: nwbfile_paths.extend(list(posix_path.rglob("*.nwb"))) - + request_id = config.pop("request_id", None) n_jobs = config.get("n_jobs", -2) # Default to all but one CPU @@ -1542,6 +1541,7 @@ def _aggregate_symlinks_in_new_directory(paths, reason="", folder_path=None) -> return folder_path + def _format_spikeglx_meta_file(bin_file_path: str) -> str: bin_file_path = Path(bin_file_path) diff --git a/src/pyflask/namespaces/neuroconv.py b/src/pyflask/namespaces/neuroconv.py index b5246c991d..4312d427bc 100644 --- a/src/pyflask/namespaces/neuroconv.py +++ b/src/pyflask/namespaces/neuroconv.py @@ -161,6 +161,7 @@ def post(self): else: return upload_multiple_filesystem_objects_to_dandi(**neuroconv_namespace.payload) + @neuroconv_namespace.route("/announce/progress") class InspectNWBFolder(Resource): @neuroconv_namespace.doc(responses={200: "Success", 400: "Bad Request", 500: "Internal server error"}) diff --git a/tests/components/InstanceManager.test.ts b/tests/components/InstanceManager.test.ts new file mode 100644 index 0000000000..7a52b8de4b --- /dev/null +++ b/tests/components/InstanceManager.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { InstanceManager } from "../../src/electron/frontend/core/components/InstanceManager"; + +const createInstance = () => { + return true +} + +const singleInstance = { + instance1: createInstance +} + +const multipleInstances = { + instance1: createInstance, + instance2: createInstance, +} + +const categorizedInstances = { + category1: { + instance1: createInstance, + instance2: createInstance, + }, + category2: { + instance3: createInstance, + instance4: createInstance, + } +} + +describe('InstanceManager', () => { + + const mountComponent = async (props = {}) => { + const element = new InstanceManager(props); + document.body.appendChild(element); + await element.updateComplete; + return element + } + + + it('renders', async () => { + const element = await mountComponent({ instances: singleInstance }); + expect(element.shadowRoot.querySelector('#content')).to.exist; + expect(element.shadowRoot.querySelector('#instance-sidebar')).to.exist; + element.remove() + }); + + it('toggles input visibility', async () => { + const element = await mountComponent({ + instances: multipleInstances, + onAdded: () => ({ value: createInstance }) + }); + + const button = element.shadowRoot.querySelector('#add-new-button'); + const newInfoContainer = element.shadowRoot.querySelector('#new-info'); + const input = newInfoContainer.querySelector('input'); + const submitButton = newInfoContainer.querySelector('nwb-button'); + + expect(newInfoContainer.hidden).to.be.true; + + button.click(); + await element.updateComplete; + expect(newInfoContainer.hidden).to.be.false; + + input.value = 'newInstance'; + submitButton.click(); + await element.updateComplete; + + expect(newInfoContainer.hidden).to.be.true; + + // NOTE: Check for new instance + expect(element.instances['newInstance']).to.exist; + + element.remove() + }); + + it('updates state correctly', async () => { + const element = await mountComponent({ + instances: multipleInstances, + }); + + element.updateState('instance1', 'inactive'); + await element.updateComplete; + + const instance = element.getInstance('instance1') + + expect(instance.status).to.equal('inactive'); + element.remove() + }); + + it('selects instance on click', async () => { + const element = await mountComponent({ + instances: multipleInstances + }); + + const instance1 = element.getInstance('instance1'); + const instance2 = element.getInstance('instance2'); + instance2.click(); + await element.updateComplete; + + expect(element.shadowRoot.querySelector(`[data-instance=${instance1.id}]`).getAttribute('hidden')).to.be.null; + expect(element.shadowRoot.querySelector(`[data-instance=${instance2.id}]`).getAttribute('hidden')).to.exist; + + // instance2.getAttribute('selected')).to.exist; + // expect(instance1.getAttribute('selected')).to.be.null; + + element.remove() + }); + + it('renders accordion for categories', async () => { + const element = await mountComponent({ + instances: categorizedInstances + }); + + await element.updateComplete; + + const accordion1 = element.shadowRoot.querySelector('nwb-accordion[name="category1"]'); + const accordion2 = element.shadowRoot.querySelector('nwb-accordion[name="instance1"]'); + expect(accordion1).to.exist; + expect(accordion2).to.not.exist; + element.remove() + }); +}); diff --git a/tests/components/forms.test.ts b/tests/components/forms.test.ts new file mode 100644 index 0000000000..a609f16984 --- /dev/null +++ b/tests/components/forms.test.ts @@ -0,0 +1,375 @@ +import { JSONSchemaForm } from '../../src/electron/frontend/core/components/JSONSchemaForm'; +import { describe, it, expect } from 'vitest'; + +import { validateOnChange } from "../../src/electron/frontend/core/validation/index.js"; +import { SimpleTable } from '../../src/electron/frontend/core/components/SimpleTable' + +import baseMetadataSchema from '../../src/schemas/base-metadata.schema' + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +const NWBFileSchemaProperties = baseMetadataSchema.properties.NWBFile.properties + +// Helper function to mount the component +async function mountComponent(props) { + + const form = new JSONSchemaForm(props); + + document.body.append(form) + await form.rendered + + return form; +} + +describe('JSONSchemaForm', () => { + it('renders text input correctly', async () => { + + const defaultValue = 'John Doe'; + const schema = { + properties: { + name: { type: 'string', title: 'Name', default: defaultValue }, + }, + }; + + const form = await mountComponent({ schema }); + await form.rendered + const nameInput = form.getFormElement('name'); + expect(nameInput).toBeDefined(); + expect(nameInput.value).toBe(defaultValue); + expect(form.resolved.name).toBe(defaultValue); + await nameInput.updateData('Jane Doe'); + expect(form.resolved.name).toBe('Jane Doe'); + }); + + it('renders number input correctly', async () => { + const schema = { + properties: { + age: { type: 'number', title: 'Age' }, + }, + }; + + const form = await mountComponent({ schema }); + const ageInput = form.getFormElement('age'); + expect(ageInput).toBeDefined(); + await ageInput.updateData(30); + expect(form.resolved.age).toBe(30); + }); + + it('renders boolean input correctly', async () => { + const schema = { + properties: { + active: { type: 'boolean', title: 'Active' }, + }, + }; + + const form = await mountComponent({ schema }); + const activeInput = form.getFormElement('active'); + expect(activeInput).toBeDefined(); + await activeInput.updateData(true); + expect(form.resolved.active).toBe(true); + }); + + + it('renders array input correctly', async () => { + const schema = { + properties: { + hobbies: { + type: 'array', + title: 'Hobbies', + items: { type: 'string' }, + }, + }, + }; + + const form = await mountComponent({ schema }); + const hobbies = form.getFormElement('hobbies'); + expect(hobbies).toBeDefined(); + expect(hobbies.value).toBeUndefined(); + await hobbies.updateData(['Reading']); + expect(form.resolved.hobbies).toEqual(['Reading']); + }); + + it('renders object input correctly', async () => { + const schema = { + properties: { + address: { + type: 'object', + title: 'Address', + properties: { + street: { type: 'string', title: 'Street' }, + city: { type: 'string', title: 'City' }, + zip: { type: 'string', title: 'ZIP Code' }, + }, + }, + }, + }; + + const form = await mountComponent({ schema }); + const streetInput = form.getFormElement(['address', 'street']); + expect(streetInput).toBeDefined(); + await streetInput.updateData('123 Main St'); + expect(form.resolved.address.street).toBe('123 Main St'); + }); + + it('renders enum input correctly', async () => { + const schema = { + properties: { + status: { + type: 'string', + title: 'Status', + enum: ['Active', 'Inactive', 'Pending'], + }, + }, + }; + + const form = await mountComponent({ schema }); + const input = form.getFormElement('status'); + expect(input).toBeDefined(); + await input.updateData('Inactive'); + expect(form.resolved.status).toBe('Inactive'); + }); + + it("renders tables correctly", async () => { + const schema = { + properties: { + users: { + type: 'array', + title: 'Users', + items: { + type: 'object', + properties: { + name: { type: 'string', title: 'Name' }, + age: { type: 'number', title: 'Age' }, + }, + }, + }, + }, + }; + + const form = await mountComponent({ + schema, + renderTable: (name, metadata, path) => { + if (name !== "Electrodes") return new SimpleTable(metadata); + else return true + }, + }); + + const users = form.getFormElement('users'); + expect(users).toBeDefined(); + await users.addRow(); + expect(users.data).toHaveLength(1); + + const row = users.getRow(0) + const newData = { name: 'John Doe', age: 30 } + await Promise.all(Object.entries(newData).map(([key, value]) => { + const cell = row.find(cell => cell.simpleTableInfo.col === key) + return cell.setInput(value) + })) + + await sleep(100) // Wait for updates to register on the table + + expect(form.resolved.users).toEqual([{ name: 'John Doe', age: 30 }]); + }) + + it('validates form correctly', async () => { + const schema = { + properties: { + name: { type: 'string', title: 'Name' }, + }, + required: ['name'], + }; + + const form = await mountComponent({ schema }); + const nameInput = form.getFormElement('name'); + expect(nameInput).toBeDefined(); + + let errors = false; + await form.validate().catch(() => errors = true); + expect(errors).toBe(true); + + await nameInput.updateData('John Doe'); + + await form.validate() + .then(() => errors = false) + .catch(() => errors = true); + + expect(errors).toBe(false); + expect(form.resolved.name).toBe('John Doe'); + + }); + + // Pop-up inputs and forms work correctly + it('creates a pop-up that submits properly', async () => { + + // Create the form + const form = new JSONSchemaForm({ + schema: { + "type": "object", + "required": ["keywords", "experimenter"], + "properties": { + "keywords": NWBFileSchemaProperties.keywords, + "experimenter": NWBFileSchemaProperties.experimenter + } + }, + }) + + document.body.append(form) + + await form.rendered + + // Validate that the results are incorrect + let errors = false + await form.validate().catch(() => errors = true) + expect(errors).toBe(true) // Is invalid + + + // Validate that changes to experimenter are valid + const experimenterInput = form.getFormElement(['experimenter']) + const experimenterButton = experimenterInput.shadowRoot.querySelector('nwb-button') + const experimenterModal = experimenterButton.onClick() + const experimenterNestedElement = experimenterModal.children[0].children[0] + const experimenterSubmitButton = experimenterModal.footer + + await sleep(1000) + + let modalFailed + try { + await experimenterSubmitButton.onClick() + modalFailed = false + } catch (e) { + modalFailed = true + } + + expect(modalFailed).toBe(true) // Is invalid + + await experimenterNestedElement.updateData(['first_name'], 'Garrett') + await experimenterNestedElement.updateData(['last_name'], 'Flynn') + + experimenterNestedElement.requestUpdate() + + await experimenterNestedElement.rendered + + try { + await experimenterSubmitButton.onClick() + modalFailed = false + } catch (e) { + modalFailed = true + } + + expect(modalFailed).toBe(false) // Is valid + + // Validate that changes to keywords are valid + const keywordsInput = form.getFormElement(['keywords']) + const input = keywordsInput.shadowRoot.querySelector('input') + const submitButton = keywordsInput.shadowRoot.querySelector('nwb-button') + const list = keywordsInput.shadowRoot.querySelector('nwb-list') + expect(list.items.length).toBe(0) // No items + + input.value = 'test' + await submitButton.onClick() + + expect(list.items.length).toBe(1) // Has item + expect(input.value).toBe('') // Input is cleared + + // Validate that the new structure is correct + const hasErrors = await form.validate(form.results).then(res => false).catch(() => true) + + expect(hasErrors).toBe(false) // Is valid + }) + + // TODO: Convert an integration + it('triggers and resolves inter-table updates correctly', async () => { + + const results = { + Ecephys: { // NOTE: This layer is required to place the properties at the right level for the hardcoded validation function + ElectrodeGroup: [{ name: 's1' }], + Electrodes: [{ group_name: 's1' }] + } + } + + const schema = { + properties: { + Ecephys: { + properties: { + ElectrodeGroup: { + type: "array", + items: { + required: ["name"], + properties: { + name: { + type: "string" + }, + }, + type: "object", + }, + }, + Electrodes: { + type: "array", + items: { + type: "object", + properties: { + group_name: { + type: "string", + }, + }, + } + }, + } + } + } + } + + + + // Add invalid electrode + const randomStringId = Math.random().toString(36).substring(7) + results.Ecephys.Electrodes.push({ group_name: randomStringId }) + + // Create the form + const form = new JSONSchemaForm({ + schema, + results, + validateOnChange, + renderTable: (name, metadata, path) => { + if (name !== "Electrodes") return new SimpleTable(metadata); + else return true + }, + }) + + document.body.append(form) + + await form.rendered + + // Validate that the results are incorrect + const errors = await form.validate().catch(() => true).catch((e) => e) + expect(errors).toBe(true) // Is invalid + + // Update the table with the missing electrode group + const table = form.getFormElement(['Ecephys', 'ElectrodeGroup']) // This is a SimpleTable where rows can be added + const idx = await table.addRow() + + const row = table.getRow(idx) + + const baseRow = table.getRow(0) + row.forEach((cell, i) => { + if (cell.simpleTableInfo.col === 'name') cell.setInput(randomStringId) // Set name to random string id + else cell.setInput(baseRow[i].value) // Otherwise carry over info + }) + + await sleep(1000) // Wait for the ElectrodeGroup table to update properly + form.requestUpdate() // Re-render the form to update the Electrodes table + + await form.rendered // Wait for the form to re-render and validate properly + + // Validate that the new structure is correct + const hasErrors = await form.validate().then(() => false).catch((e) => true) + + expect(hasErrors).toBe(false) // Is valid + + }) + + + +}); diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 66b8c91d98..e3bf6e764f 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -8,18 +8,10 @@ import { createMockGlobalState } from './utils' import { Validator } from 'jsonschema' import { updateResultsFromSubjects } from '../src/electron/frontend/utils/data' -import { JSONSchemaForm } from '../src/electron/frontend/core/components/JSONSchemaForm' -import { validateOnChange } from "../src/electron/frontend/core/validation/index.js"; -import { SimpleTable } from '../src/electron/frontend/core/components/SimpleTable' - -function sleep(ms) { - return new Promise(resolve => setTimeout(resolve, ms)); -} var validator = new Validator(); -const NWBFileSchemaProperties = baseMetadataSchema.properties.NWBFile.properties describe('metadata is specified correctly', () => { @@ -45,174 +37,3 @@ test('removing all existing sessions will maintain the related subject entry on updateResultsFromSubjects(results, subjects) expect(Object.keys(results)).toEqual(Object.keys(copy)) }) - -const popupSchemas = { - "type": "object", - "required": ["keywords", "experimenter"], - "properties": { - "keywords": NWBFileSchemaProperties.keywords, - "experimenter": NWBFileSchemaProperties.experimenter - } -} - -// Pop-up inputs and forms work correctly -test('pop-up inputs work correctly', async () => { - - const results = {} - - // Create the form - const form = new JSONSchemaForm({ schema: popupSchemas, results }) - - document.body.append(form) - - await form.rendered - - // Validate that the results are incorrect - let errors = false - await form.validate().catch(() => errors = true) - expect(errors).toBe(true) // Is invalid - - - // Validate that changes to experimenter are valid - const experimenterInput = form.getFormElement(['experimenter']) - const experimenterButton = experimenterInput.shadowRoot.querySelector('nwb-button') - const experimenterModal = experimenterButton.onClick() - const experimenterNestedElement = experimenterModal.children[0].children[0] - const experimenterSubmitButton = experimenterModal.footer - - await sleep(1000) - - let modalFailed - try { - await experimenterSubmitButton.onClick() - modalFailed = false - } catch (e) { - modalFailed = true - } - - expect(modalFailed).toBe(true) // Is invalid - - experimenterNestedElement.updateData(['first_name'], 'Garrett') - experimenterNestedElement.updateData(['last_name'], 'Flynn') - - experimenterNestedElement.requestUpdate() - - await experimenterNestedElement.rendered - - try { - await experimenterSubmitButton.onClick() - modalFailed = false - } catch (e) { - modalFailed = true - } - - expect(modalFailed).toBe(false) // Is valid - - // Validate that changes to keywords are valid - const keywordsInput = form.getFormElement(['keywords']) - const input = keywordsInput.shadowRoot.querySelector('input') - const submitButton = keywordsInput.shadowRoot.querySelector('nwb-button') - const list = keywordsInput.shadowRoot.querySelector('nwb-list') - expect(list.items.length).toBe(0) // No items - - input.value = 'test' - await submitButton.onClick() - - expect(list.items.length).toBe(1) // Has item - expect(input.value).toBe('') // Input is cleared - - // Validate that the new structure is correct - const hasErrors = await form.validate(form.results).then(res => false).catch(() => true) - - expect(hasErrors).toBe(false) // Is valid -}) - -// TODO: Convert an integration -test('inter-table updates are triggered', async () => { - - const results = { - Ecephys: { // NOTE: This layer is required to place the properties at the right level for the hardcoded validation function - ElectrodeGroup: [{ name: 's1' }], - Electrodes: [{ group_name: 's1' }] - } - } - - const schema = { - properties: { - Ecephys: { - properties: { - ElectrodeGroup: { - type: "array", - items: { - required: ["name"], - properties: { - name: { - type: "string" - }, - }, - type: "object", - }, - }, - Electrodes: { - type: "array", - items: { - type: "object", - properties: { - group_name: { - type: "string", - }, - }, - } - }, - } - } - } - } - - - - // Add invalid electrode - const randomStringId = Math.random().toString(36).substring(7) - results.Ecephys.Electrodes.push({ group_name: randomStringId }) - - // Create the form - const form = new JSONSchemaForm({ - schema, - results, - validateOnChange, - renderTable: (name, metadata, path) => { - if (name !== "Electrodes") return new SimpleTable(metadata); - else return true - }, - }) - - document.body.append(form) - - await form.rendered - - // Validate that the results are incorrect - const errors = await form.validate().catch(() => true).catch((e) => e) - expect(errors).toBe(true) // Is invalid - - // Update the table with the missing electrode group - const table = form.getFormElement(['Ecephys', 'ElectrodeGroup']) // This is a SimpleTable where rows can be added - const idx = await table.addRow() - - const row = table.getRow(idx) - - const baseRow = table.getRow(0) - row.forEach((cell, i) => { - if (cell.simpleTableInfo.col === 'name') cell.setInput(randomStringId) // Set name to random string id - else cell.setInput(baseRow[i].value) // Otherwise carry over info - }) - - await sleep(1000) // Wait for the ElectrodeGroup table to update properly - form.requestUpdate() // Re-render the form to update the Electrodes table - - await form.rendered // Wait for the form to re-render and validate properly - - // Validate that the new structure is correct - const hasErrors = await form.validate().then(() => false).catch((e) => true) - - expect(hasErrors).toBe(false) // Is valid -}) diff --git a/tests/utils.test.ts b/tests/utils.test.ts index a05d2fd6f2..c53dba8f8c 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -372,9 +372,9 @@ describe('Randomization Utilities', () => { }); it('should return a string of expected length', () => { - const result = random.getRandomString(); - expect(result.length).toBeGreaterThanOrEqual(5); // Length might vary slightly due to the nature of random - expect(result.length).toBeLessThanOrEqual(11); // Usually the length is around 7-10 characters + const len = 10; + const result = random.getRandomString(len); + expect(result.length).toEqual(len); // Length is always the specified length }); it('should return a different string each time it is called', () => { diff --git a/vite.config.js b/vite.config.js index e79e9e8739..5667e299b7 100644 --- a/vite.config.js +++ b/vite.config.js @@ -27,13 +27,6 @@ export default defineConfig({ "src/electron/frontend/utils/electron.ts", "src/electron/frontend/utils/auto-update.ts", - // High-Level App Configuration - "src/electron/frontend/core/index.ts", - "src/electron/frontend/core/pages.js", - "src/electron/frontend/core/dependencies.js", - "src/electron/frontend/core/globals.js", - "src/electron/frontend/core/errors.ts", - // Server Communication "src/electron/frontend/core/server", "src/electron/frontend/utils/run.ts", @@ -42,10 +35,30 @@ export default defineConfig({ // Pure Native Rendering Interaction "src/electron/frontend/utils/table.ts", + // High-Level App Configuration + "src/electron/frontend/core/index.ts", + "src/electron/frontend/core/pages.js", + "src/electron/frontend/core/notifications.ts", + "src/electron/frontend/core/lotties.ts", + "src/electron/frontend/core/globals.js", + "src/electron/frontend/core/errors.ts", + + // Dashboard-Related Components + "src/electron/frontend/core/components/Dashboard.js", + "src/electron/frontend/core/components/Main.js", + "src/electron/frontend/core/components/Footer.js", + "src/electron/frontend/core/components/NavigationSidebar.js", + "src/electron/frontend/core/components/StatusBar.js", + "src/electron/frontend/core/components/sidebar.js", + + // Just rendering + "src/electron/frontend/core/components/CodeBlock.js", + // Unclear how to test "src/electron/frontend/utils/popups.ts", "src/electron/frontend/utils/download.ts", "src/electron/frontend/utils/upload.ts", + "src/electron/frontend/core/components/FileSystemSelector.js", // Uses Electron dialog ], }, },