diff --git a/package.json b/package.json index 23e6e7005b..bfadc05e64 100644 --- a/package.json +++ b/package.json @@ -23,10 +23,12 @@ "build:electron:linux": "electron-builder build --linux --publish never", "test": "npm run test:app && npm run test:server", "test:app": "vitest run --exclude \"**/pipelines.test.ts\"", + "test:app:covered": "vitest run --exclude \"**/e2e/*.test.ts\"", "test:tutorial": "vitest tutorial", "test:pipelines": "vitest pipelines", "test:progress": "vitest progress", "test:metadata": "vitest metadata", + "test:coverage:minimal": "npm run test:app:covered && npm run coverage:server", "test:server": "pytest src/pyflask/tests/ -s -vv", "wait5s": "node -e \"setTimeout(() => process.exit(0),5000)\"", "test:executable": "concurrently -n EXE,TEST --kill-others --success first \"node tests/testPyinstallerExecutable.js --port 3434 --forever\" \"npm run wait5s && pytest src/pyflask/tests/ -s --target http://localhost:3434\"", diff --git a/src/electron/frontend/core/components/BasicTable.js b/src/electron/frontend/core/components/BasicTable.js index 2c133f6a73..ad047d6d23 100644 --- a/src/electron/frontend/core/components/BasicTable.js +++ b/src/electron/frontend/core/components/BasicTable.js @@ -1,10 +1,10 @@ import { LitElement, css, html, unsafeCSS } from "lit"; import { styleMap } from "lit/directives/style-map.js"; -import { header } from "./forms/utils"; +import { header } from "../../utils/text"; import { checkStatus } from "../validation"; import { emojiFontFamily, errorHue, warningHue } from "./globals"; -import * as promises from "../promises"; +import * as promises from "../../utils/promises"; import "./Button"; import { sortTable } from "./Table"; diff --git a/src/electron/frontend/core/components/DandiResults.js b/src/electron/frontend/core/components/DandiResults.js index 210757647b..9c4360aef2 100644 --- a/src/electron/frontend/core/components/DandiResults.js +++ b/src/electron/frontend/core/components/DandiResults.js @@ -1,7 +1,7 @@ import { LitElement, css, html } from "lit"; import { get } from "dandi"; -import { isStaging, getAPIKey } from "./pages/uploads/utils"; +import { isStaging, getAPIKey } from "../../utils/upload"; export class DandiResults extends LitElement { static get styles() { diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index ce93c02ced..9606356a35 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -1,5 +1,4 @@ import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; import { Main, checkIfPageIsSkipped } from "./Main.js"; import { Sidebar } from "./sidebar.js"; @@ -32,36 +31,14 @@ import "../../../../../node_modules/fomantic-ui/dist/components/accordion.min.cs import "../../../../../node_modules/@sweetalert2/theme-bulma/bulma.css"; // import "../../node_modules/intro.js/minified/introjs.min.css" import "../../assets/css/guided.css"; -import { isElectron } from "../../utils/electron.js"; +import { isElectron } from "../../utils/electron"; import { isStorybook, reloadPageToHome } from "../globals.js"; import { getCurrentProjectName, updateAppProgress } from "../progress/index.js"; // import "https://jsuites.net/v4/jsuites.js" // import "https://bossanova.uk/jspreadsheet/v4/jexcel.js" -const componentCSS = ` - :host { - display: flex; - height: 100%; - width: 100%; - } - - nwb-main { - background: #fff; - border-top: 1px solid #c3c3c3; - } -`; - export class Dashboard extends LitElement { - static get styles() { - const style = useGlobalStyles( - componentCSS, - (sheet) => sheet.href && sheet.href.includes("bootstrap"), - this.shadowRoot - ); - return style; - } - static get properties() { return { renderNameInSidebar: { type: Boolean, reflect: true }, diff --git a/src/electron/frontend/core/components/DateTimeSelector.js b/src/electron/frontend/core/components/DateTimeSelector.js index a8ba90cf8e..66c3dd5305 100644 --- a/src/electron/frontend/core/components/DateTimeSelector.js +++ b/src/electron/frontend/core/components/DateTimeSelector.js @@ -1,5 +1,5 @@ import { LitElement, css } from "lit"; -import { getTimezoneOffset, formatTimezoneOffset } from "../../../../schemas/timezone.schema"; +import { getTimezoneOffset, formatTimezoneOffset } from "../../utils/time"; // Function to format the GMT offset export function extractISOString(date = new Date(), { offset = false, timezone = undefined } = {}) { diff --git a/src/electron/frontend/core/components/forms/GlobalFormModal.ts b/src/electron/frontend/core/components/GlobalFormModal.ts similarity index 93% rename from src/electron/frontend/core/components/forms/GlobalFormModal.ts rename to src/electron/frontend/core/components/GlobalFormModal.ts index bd0f919bcc..52cc393e94 100644 --- a/src/electron/frontend/core/components/forms/GlobalFormModal.ts +++ b/src/electron/frontend/core/components/GlobalFormModal.ts @@ -1,11 +1,11 @@ -import { Modal } from "../Modal" -import { Page } from "../pages/Page.js" -import { Button } from "../Button.js" -import { JSONSchemaForm } from "../JSONSchemaForm.js" - -import { onThrow } from "../../errors"; -import { merge } from "../pages/utils.js"; -import { save } from "../../progress/index.js"; +import { Modal } from "./Modal" +import { Page } from "./pages/Page.js" +import { Button } from "./Button.js" +import { JSONSchemaForm } from "./JSONSchemaForm.js" + +import { onThrow } from "../errors"; +import { merge } from "../../utils/data"; +import { save } from "../progress/index.js"; type SingleIgnorePropsLevel = { [x:string]: true, diff --git a/src/electron/frontend/core/components/preview/inspector/InspectorList.js b/src/electron/frontend/core/components/InspectorList.js similarity index 95% rename from src/electron/frontend/core/components/preview/inspector/InspectorList.js rename to src/electron/frontend/core/components/InspectorList.js index b003dacecf..4e5dc56bc7 100644 --- a/src/electron/frontend/core/components/preview/inspector/InspectorList.js +++ b/src/electron/frontend/core/components/InspectorList.js @@ -1,6 +1,6 @@ import { LitElement, css, html } from "lit"; -import { List } from "../../List"; -import { getMessageType, isErrorImportance } from "../../../validation"; +import { List } from "./List"; +import { getMessageType, isErrorImportance } from "../validation"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; diff --git a/src/electron/frontend/core/components/instances/item.ts b/src/electron/frontend/core/components/InstanceListItem.ts similarity index 98% rename from src/electron/frontend/core/components/instances/item.ts rename to src/electron/frontend/core/components/InstanceListItem.ts index 064522c060..61ebc2afec 100644 --- a/src/electron/frontend/core/components/instances/item.ts +++ b/src/electron/frontend/core/components/InstanceListItem.ts @@ -1,6 +1,6 @@ import { LitElement, css, html, unsafeCSS } from "lit"; -import { errorHue, errorSymbol, successHue, successSymbol, warningHue, warningSymbol, emojiFontFamily } from "../globals"; +import { errorHue, errorSymbol, successHue, successSymbol, warningHue, warningSymbol, emojiFontFamily } from "./globals"; export class InstanceListItem extends LitElement { diff --git a/src/electron/frontend/core/components/InstanceManager.js b/src/electron/frontend/core/components/InstanceManager.js index 3b9dc49bbb..ad0d8a1e6e 100644 --- a/src/electron/frontend/core/components/InstanceManager.js +++ b/src/electron/frontend/core/components/InstanceManager.js @@ -2,7 +2,7 @@ import { LitElement, css, html } from "lit"; import "./Button"; import { notify } from "../dependencies"; import { Accordion } from "./Accordion"; -import { InstanceListItem } from "./instances/item"; +import { InstanceListItem } from "./InstanceListItem"; import { checkStatus } from "../validation"; export class InstanceManager extends LitElement { diff --git a/src/electron/frontend/core/components/JSONSchemaForm.js b/src/electron/frontend/core/components/JSONSchemaForm.js index 2a3d9503ce..57c5b0f5ed 100644 --- a/src/electron/frontend/core/components/JSONSchemaForm.js +++ b/src/electron/frontend/core/components/JSONSchemaForm.js @@ -4,17 +4,19 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { Accordion } from "./Accordion"; import { checkStatus } from "../validation"; -import { header, replaceRefsWithValue } from "./forms/utils"; -import { resolve } from "../promises"; -import { merge } from "./pages/utils"; -import { resolveProperties } from "./pages/guided-mode/data/utils"; +import { header } from "../../utils/text"; +import { resolveAsJSONSchema } from "../../utils/data"; +import { resolve } from "../../utils/promises"; +import { merge } from "../../utils/data"; +import { resolveProperties } from "../../utils/data"; import { JSONSchemaInput, getEditableItems } from "./JSONSchemaInput"; -import { InspectorListItem } from "./preview/inspector/InspectorList"; +import { InspectorListItem } from "./InspectorList"; import { Validator } from "jsonschema"; import { successHue, warningHue, errorHue } from "./globals"; import { Button } from "./Button"; +import { isObject } from "../../utils/typecheck"; const encode = (str) => { try { @@ -69,11 +71,7 @@ const additionalPropPattern = "additional"; const templateNaNMessage = `
Type NaN to represent an unknown value.`; -var validator = new Validator(); - -const isObject = (item) => { - return item && typeof item === "object" && !Array.isArray(item); -}; +const validator = new Validator(); export const getIgnore = (o, path) => { if (typeof path === "string") path = path.split("."); @@ -658,7 +656,7 @@ export class JSONSchemaForm extends LitElement { set schema(schema) { this.#schema = schema; - this.#schema = replaceRefsWithValue(schema); + this.#schema = resolveAsJSONSchema(schema); } get schema() { diff --git a/src/electron/frontend/core/components/JSONSchemaInput.js b/src/electron/frontend/core/components/JSONSchemaInput.js index 0a3b150a57..f9966222f6 100644 --- a/src/electron/frontend/core/components/JSONSchemaInput.js +++ b/src/electron/frontend/core/components/JSONSchemaInput.js @@ -3,20 +3,22 @@ import { unsafeHTML } from "lit/directives/unsafe-html.js"; import { FilesystemSelector } from "./FileSystemSelector"; import { BasicTable } from "./BasicTable"; -import { header, tempPropertyKey, tempPropertyValueKey } from "./forms/utils"; +import { header } from "../../utils/text"; +import { tempPropertyKey, tempPropertyValueKey } from "./globals.js"; import { Button } from "./Button"; import { List } from "./List"; import { Modal } from "./Modal"; -import { capitalize } from "./forms/utils"; +import { capitalize } from "../../utils/text"; import { JSONSchemaForm, getIgnore } from "./JSONSchemaForm"; import { Search } from "./Search"; import tippy from "tippy.js"; -import { merge } from "./pages/utils"; +import { merge } from "../../utils/data"; import { OptionalSection } from "./OptionalSection"; -import { InspectorListItem } from "./preview/inspector/InspectorList"; +import { InspectorListItem } from "./InspectorList.js"; import { renderDateTime, resolveDateTime } from "./DateTimeSelector"; +import { isObject } from "../../utils/typecheck"; const isDevelopment = !!import.meta.env; @@ -276,8 +278,7 @@ export function createTable(fullPath, { onUpdate, onThrow, overrides = {} }) { } // Schema or value indicates editable object -export const isEditableObject = (schema, value) => - schema.type === "object" || (value && typeof value === "object" && !Array.isArray(value)); +export const isEditableObject = (schema, value) => schema.type === "object" || isObject(value); export const isAdditionalProperties = (pattern) => pattern === "additional"; export const isPatternProperties = (pattern) => pattern && !isAdditionalProperties(pattern); diff --git a/src/electron/frontend/core/components/Main.js b/src/electron/frontend/core/components/Main.js index 7270ac1ec7..e34b621a44 100644 --- a/src/electron/frontend/core/components/Main.js +++ b/src/electron/frontend/core/components/Main.js @@ -1,5 +1,4 @@ import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; import { GuidedFooter } from "./pages/guided-mode/GuidedFooter.js"; import { GuidedHeader } from "./pages/guided-mode/GuidedHeader.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; @@ -19,22 +18,7 @@ export const checkIfPageIsSkipped = (page, workflowValues = {}) => { return false; }; -const componentCSS = ` - :host { - display: grid; - grid-template-rows: fit-content(100%) 1fr fit-content(100%); - } -`; - export class Main extends LitElement { - static get styles() { - return useGlobalStyles( - componentCSS, - (sheet) => sheet.href && sheet.href.includes("bootstrap"), - this.shadowRoot - ); - } - static get properties() { return { toRender: { type: Object, reflect: false }, diff --git a/src/electron/frontend/core/components/preview/NWBFilePreview.js b/src/electron/frontend/core/components/NWBFilePreview.js similarity index 93% rename from src/electron/frontend/core/components/preview/NWBFilePreview.js rename to src/electron/frontend/core/components/NWBFilePreview.js index 5c6cafdad3..84e7a61d5a 100644 --- a/src/electron/frontend/core/components/preview/NWBFilePreview.js +++ b/src/electron/frontend/core/components/NWBFilePreview.js @@ -1,12 +1,13 @@ import { LitElement, css, html } from "lit"; -import { InspectorList } from "./inspector/InspectorList"; +import { InspectorList } from "./InspectorList"; import { Neurosift, getURLFromFilePath } from "./Neurosift"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; -import { run } from "../pages/guided-mode/options/utils"; +import { run } from "../../utils/run"; + import { until } from "lit/directives/until.js"; -import { InstanceManager } from "../InstanceManager"; -import { path } from "../../../utils/electron.js"; -import { FullScreenToggle } from "../FullScreenToggle"; +import { InstanceManager } from "./InstanceManager"; +import { path } from "../../utils/electron"; +import { FullScreenToggle } from "./FullScreenToggle"; export function getSharedPath(array) { array = array.map((str) => str.replace(/\\/g, "/")); // Convert to Mac-style path diff --git a/src/electron/frontend/core/components/NavigationSidebar.js b/src/electron/frontend/core/components/NavigationSidebar.js index c30ca4ee1b..072e1531fe 100644 --- a/src/electron/frontend/core/components/NavigationSidebar.js +++ b/src/electron/frontend/core/components/NavigationSidebar.js @@ -1,27 +1,14 @@ import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; const autoOpenValue = Symbol("SECTION_AUTO_OPEN"); -const componentCSS = ` - -`; - function isHTML(str) { var doc = new DOMParser().parseFromString(str, "text/html"); return Array.from(doc.body.childNodes).some((node) => node.nodeType === 1); } export class NavigationSidebar extends LitElement { - static get styles() { - return useGlobalStyles( - componentCSS, - (sheet) => sheet.href && sheet.href.includes("bootstrap"), - this.shadowRoot - ); - } - static get properties() { return { sections: { type: Object, reflect: false }, diff --git a/src/electron/frontend/core/components/preview/Neurosift.js b/src/electron/frontend/core/components/Neurosift.js similarity index 91% rename from src/electron/frontend/core/components/preview/Neurosift.js rename to src/electron/frontend/core/components/Neurosift.js index 3f85327d87..4381072054 100644 --- a/src/electron/frontend/core/components/preview/Neurosift.js +++ b/src/electron/frontend/core/components/Neurosift.js @@ -1,8 +1,8 @@ import { LitElement, css, html } from "lit"; -import { Loader } from "../Loader"; -import { FullScreenToggle } from "../FullScreenToggle"; -import { baseUrl } from "../../server/globals"; +import { Loader } from "./Loader"; +import { FullScreenToggle } from "./FullScreenToggle"; +import { baseUrl } from "../server/globals"; export function getURLFromFilePath(file, projectName) { const regexp = new RegExp(`.+(${projectName}.+)`); diff --git a/src/electron/frontend/core/components/ProgressBar.ts b/src/electron/frontend/core/components/ProgressBar.ts index 8eeb71dab8..97d1eff230 100644 --- a/src/electron/frontend/core/components/ProgressBar.ts +++ b/src/electron/frontend/core/components/ProgressBar.ts @@ -1,7 +1,7 @@ import { LitElement, html, css, unsafeCSS } from 'lit'; -import { humanReadableBytes } from './utils/size'; +import { humanReadableBytes } from '../../utils/bytes'; export type ProgressProps = { size?: string, diff --git a/src/electron/frontend/core/components/Search.js b/src/electron/frontend/core/components/Search.js index 66a988014e..f348b4b265 100644 --- a/src/electron/frontend/core/components/Search.js +++ b/src/electron/frontend/core/components/Search.js @@ -5,6 +5,7 @@ import searchSVG from "../../assets/icons/search.svg?raw"; import tippy from "tippy.js"; import { unsafeHTML } from "lit/directives/unsafe-html.js"; +import { isObject } from "../../utils/typecheck"; const ALTERNATIVE_MODES = ["input", "append"]; @@ -45,9 +46,7 @@ export class Search extends LitElement { #value; - #isObject(value = this.#value) { - return value && typeof value === "object"; - } + #isObject = (value = this.#value) => isObject(value); #getOption = ({ label, value } = {}) => { return this.options.find((item) => { diff --git a/src/electron/frontend/core/components/SimpleTable.js b/src/electron/frontend/core/components/SimpleTable.js index 679dee38db..1db95ee1ad 100644 --- a/src/electron/frontend/core/components/SimpleTable.js +++ b/src/electron/frontend/core/components/SimpleTable.js @@ -1,10 +1,10 @@ import { LitElement, css, html, unsafeCSS } from "lit"; -import { header, tempPropertyValueKey } from "./forms/utils"; +import { header } from "../../utils/text"; import { checkStatus } from "../validation"; import { TableCell } from "./table/Cell"; import { ContextMenu } from "./table/ContextMenu"; -import { emojiFontFamily, errorHue, warningHue } from "./globals"; +import { emojiFontFamily, errorHue, tempPropertyValueKey, warningHue } from "./globals"; import { Loader } from "./Loader"; import { styleMap } from "lit/directives/style-map.js"; @@ -14,7 +14,7 @@ import tippy from "tippy.js"; import { sortTable, getEditable } from "./Table"; import { NestedInputCell } from "./table/cells/input"; import { getIgnore } from "./JSONSchemaForm"; -import { merge } from "./pages/utils"; +import { merge } from "../../utils/data"; var isMac = navigator.platform.toUpperCase().indexOf("MAC") >= 0; diff --git a/src/electron/frontend/core/components/status/StatusBar.ts b/src/electron/frontend/core/components/StatusBar.ts similarity index 100% rename from src/electron/frontend/core/components/status/StatusBar.ts rename to src/electron/frontend/core/components/StatusBar.ts diff --git a/src/electron/frontend/core/components/status/StatusIndicator.ts b/src/electron/frontend/core/components/StatusIndicator.ts similarity index 99% rename from src/electron/frontend/core/components/status/StatusIndicator.ts rename to src/electron/frontend/core/components/StatusIndicator.ts index ded6e1b2d6..65dc868dcd 100644 --- a/src/electron/frontend/core/components/status/StatusIndicator.ts +++ b/src/electron/frontend/core/components/StatusIndicator.ts @@ -5,7 +5,7 @@ import { successHue, warningHue, issueHue -} from "../globals"; +} from "./globals"; export type StatusIndicatorProps = { label: string | any, diff --git a/src/electron/frontend/core/components/Table.js b/src/electron/frontend/core/components/Table.js index f4a7c7956a..6c7a669837 100644 --- a/src/electron/frontend/core/components/Table.js +++ b/src/electron/frontend/core/components/Table.js @@ -1,6 +1,6 @@ import { LitElement, html } from "lit"; import { Handsontable, css } from "./hot"; -import { header } from "./forms/utils"; +import { header } from "../../utils/text"; import { errorHue, warningHue } from "./globals"; import { checkStatus } from "../validation"; import { emojiFontFamily } from "./globals"; diff --git a/src/electron/frontend/core/components/forms/utils.ts b/src/electron/frontend/core/components/forms/utils.ts deleted file mode 100644 index 43d09296e1..0000000000 --- a/src/electron/frontend/core/components/forms/utils.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { merge } from '../pages/utils' - -const toCapitalizeAll = ['nwb', 'api', 'id'] -const toCapitalizeNone = ['or', 'and'] - - -export const createRandomString = () => Math.random().toString(36).substring(7); -export const tempPropertyKey = createRandomString(); -export const tempPropertyValueKey = createRandomString(); - -export const capitalize = (str: string) => { - const lowerCase = str.toLowerCase() - return toCapitalizeAll.includes(lowerCase) ? str.toUpperCase() : (toCapitalizeNone.includes(lowerCase) ? lowerCase : str[0].toUpperCase() + str.slice(1)) -} - - -export const header = (headerStr: string) => headerStr.split(/[_\s]/).filter(str => !!str).map(capitalize).join(' ') - -export const textToArray = (value: string) => value.split("\n") - .map((str) => str.trim()) - .filter((str) => str) // Only keep strings that are not empty - - - export const replaceRefsWithValue = ( - schema: any, - path: string[] = [], - parent: { [x:string]: any } = structuredClone(schema) - ) => { - - if (schema && typeof schema === "object" && !Array.isArray(schema)) { - - const copy = { ...schema }; - - for (let propName in copy) { - const prop = copy[propName]; - if (prop && typeof prop === "object" && !Array.isArray(prop)) { - const internalCopy = (copy[propName] = { ...prop }); - const refValue = internalCopy["$ref"] - const allOfValue = internalCopy['allOf'] - if (allOfValue) { - copy [propName]= allOfValue.reduce((acc, curr) => { - const result = replaceRefsWithValue({ _temp: curr}, path, parent) - const resolved = result._temp - return merge(resolved, acc) - }, {}) - } - else if (refValue) { - - const refPath = refValue.split('/').slice(1) // NOTE: Assume from base - const resolved = refPath.reduce((acc, key) => acc[key], parent) - - if (resolved) copy[propName] = resolved; - else delete copy[propName] - } else { - for (let key in internalCopy) { - const fullPath = [...path, propName, key]; - internalCopy[key] = replaceRefsWithValue(internalCopy[key], fullPath, parent); - } - } - } - } - - return copy as { [x:string]: any } - } - - return schema; - } diff --git a/src/electron/frontend/core/components/globals.js b/src/electron/frontend/core/components/globals.js index 3f51572b93..45fa0b372a 100644 --- a/src/electron/frontend/core/components/globals.js +++ b/src/electron/frontend/core/components/globals.js @@ -1,4 +1,5 @@ import { css } from "lit"; +import { getRandomString } from "../../utils/random"; export const errorHue = 0; export const warningHue = 57; @@ -10,3 +11,6 @@ export const warningSymbol = css`⚠️`; export const successSymbol = css`✅`; export const emojiFontFamily = `"Twemoji Mozilla", "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji", "EmojiOne Color", "Android Emoji", sans-serif;`; + +export const tempPropertyKey = getRandomString(); +export const tempPropertyValueKey = getRandomString(); diff --git a/src/electron/frontend/core/components/multiselect/MultiSelectForm.js b/src/electron/frontend/core/components/multiselect/MultiSelectForm.js deleted file mode 100644 index 91b313c44a..0000000000 --- a/src/electron/frontend/core/components/multiselect/MultiSelectForm.js +++ /dev/null @@ -1,158 +0,0 @@ -import { LitElement, css, html } from "lit"; - -// Adapted from https://web.dev/building-a-multi-select-component/ - -const componentCSS = ` - - * { - box-sizing: border-box; - } - - :host { - display: inline-block; - } - - :host > div { - border: 1px solid #ccc; - border-radius: 4px; - background: white; - padding: 25px; - display: inline-block; - } - - form { - display: grid; - gap: 2ch; - } - - @media (pointer: coarse) { - select[multiple] { - display: block; - } - } - - fieldset { - padding: 2ch; - border: 1px solid gray; - - & > div + div { - margin-block-start: 2ch; - } - } - - legend { - font-weight: bold; - } - - fieldset > div { - display: flex; - gap: 2ch; - align-items: baseline; - } -`; - -export class MultiSelectForm extends LitElement { - static get styles() { - return css([componentCSS]); - } - - static get properties() { - return { - options: { type: Object, reflect: true }, - selected: { type: Object, reflect: false }, - }; - } - - constructor(props = {}) { - super(); - this.options = props.options ?? {}; - this.selected = props.selected ?? {}; - } - - attributeChangedCallback(changedProperties, oldValue, newValue) { - super.attributeChangedCallback(changedProperties, oldValue, newValue); - if (changedProperties === "options") this.requestUpdate(); - } - - // NOTE: We can move these into their own components in the future - async updated() { - const dataFormatsForm = (this.shadowRoot ?? this).querySelector("#neuroconv-data-formats-form"); - dataFormatsForm.innerHTML = ""; // Clear the form - - const formats = this.options; - - if (formats.message) { - throw new Error(formats.message); - } - - // Currently supports two levels of fields - let modalities = {}; - for (let className in formats) { - const format = formats[className]; - const name = format.name ?? className; - - let modality = modalities[format.modality]; - if (!modality) { - const fieldset = document.createElement("fieldset"); - const legend = document.createElement("legend"); - legend.textContent = format.modality; - fieldset.appendChild(legend); - dataFormatsForm.appendChild(fieldset); - - modality = modalities[format.modality] = { - form: fieldset, - techniques: {}, - }; - } - - // Place in technique or modality div - const technique = format.technique; - let targetInfo = modality; - if (technique) { - targetInfo = modality.techniques[technique]; - if (!targetInfo) { - const fieldset = document.createElement("fieldset"); - const legend = document.createElement("legend"); - legend.textContent = technique; - fieldset.appendChild(legend); - modality.form.appendChild(fieldset); - - targetInfo = modality.techniques[technique] = { - form: fieldset, - }; - } - } - - const form = targetInfo.form; - const div = document.createElement("div"); - const input = document.createElement("input"); - input.type = "checkbox"; - input.value = name; - input.name = name; - div.appendChild(input); - const label = document.createElement("label"); - - if (this.selected[name]) input.checked = true; - - input.onchange = (ev) => { - this.selected[name] = input.checked; - }; - label.for = name; - label.textContent = name; - div.appendChild(label); - form.appendChild(div); - } - } - - render() { - return html` -
-
- -
-
- `; - } -} - -customElements.get("nwb-multiselect-form") || customElements.define("nwb-multiselect-form", MultiSelectForm); diff --git a/src/electron/frontend/core/components/pages/Page.js b/src/electron/frontend/core/components/pages/Page.js index 672068b1eb..886987dfbe 100644 --- a/src/electron/frontend/core/components/pages/Page.js +++ b/src/electron/frontend/core/components/pages/Page.js @@ -1,15 +1,16 @@ import { LitElement, html } from "lit"; -import { run } from "./guided-mode/options/utils.js"; +import { run } from "../../../utils/run"; import { get, save } from "../../progress/index.js"; import { dismissNotification, notify } from "../../dependencies.js"; import { isStorybook } from "../../globals.js"; -import { randomizeElements, mapSessions, merge } from "./utils"; +import { mapSessions, merge } from "../../../utils/data"; +import { getRandomSample } from "../../../utils/random"; -import { resolveMetadata } from "./guided-mode/data/utils.js"; +import { resolveMetadata } from "../../../utils/data"; import Swal from "sweetalert2"; -import { createProgressPopup } from "../utils/progress.js"; +import { createProgressPopup } from "../../../utils/popups"; export class Page extends LitElement { // static get styles() { @@ -153,7 +154,7 @@ export class Page extends LitElement { // Filter the sessions to run if (typeof original === "number") - toRun = randomizeElements(toRun, original); // Grab a random set of sessions + toRun = getRandomSample(toRun, original); // Grab a random set of sessions else if (typeof original === "string") toRun = toRun.filter(({ subject }) => subject === original); else if (typeof original === "function") toRun = toRun.filter(original); diff --git a/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js b/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js index a1edff01cc..6884f40597 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js +++ b/src/electron/frontend/core/components/pages/guided-mode/GuidedStart.js @@ -2,7 +2,7 @@ import { html } from "lit"; import { Page } from "../Page.js"; import "./GuidedFooter"; import { InfoBox } from "../../InfoBox.js"; -import { InspectorListItem } from "../../preview/inspector/InspectorList.js"; +import { InspectorListItem } from "../../inspector/InspectorList.js"; import { sections } from "../globals"; export class GuidedStartPage extends Page { diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js index 035e576f31..fabfbe6a39 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedBackendConfiguration.js @@ -3,21 +3,21 @@ import { JSONSchemaForm, get } from "../../../JSONSchemaForm.js"; import { ManagedPage } from "./ManagedPage.js"; import { onThrow } from "../../../../errors"; -import { merge } from "../../utils.js"; +import { merge } from "../../../../../utils/data"; +import { run } from "../../../../../utils/run"; import { html } from "lit"; -import { run } from "../options/utils.js"; import { until } from "lit/directives/until.js"; -import { resolve } from "../../../../promises"; +import { resolve } from "../../../../../utils/promises"; import { InstanceManager } from "../../../InstanceManager.js"; -import { InspectorListItem } from "../../../preview/inspector/InspectorList.js"; +import { InspectorListItem } from "../../../InspectorList.js"; import { getResourceUsageBytes } from "../../../../validation/backend-configuration"; import { resolveBackendResults, updateSchema } from "../../../../../../../schemas/backend-configuration.schema"; -import { getInfoFromId } from "./utils.js"; +import { getInfoFromId } from "../../../../../utils/data"; const itemIgnore = { full_shape: true, diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js index a5db5846b9..e06a30e544 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedMetadata.js @@ -10,17 +10,18 @@ import { resolveMetadata, getInfoFromId, drillSchemaProperties, - resolveFromPath, -} from "./utils"; +} from "./../../../../../utils/data"; + +import { merge } from "../../../../../utils/data"; +import { header } from "../../../../../utils/text"; +import { tempPropertyKey } from "./../../../globals.js"; import Swal from "sweetalert2"; import { SimpleTable } from "../../../SimpleTable.js"; import { onThrow } from "../../../../errors"; -import { merge } from "../../utils"; -import { NWBFilePreview } from "../../../preview/NWBFilePreview.js"; -import { header, tempPropertyKey } from "../../../forms/utils"; +import { NWBFilePreview } from "../../../NWBFilePreview.js"; -import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; +import { createGlobalFormModal } from "../../../GlobalFormModal"; import { Button } from "../../../Button.js"; import globalIcon from "../../../../../assets/icons/global.svg?raw"; @@ -225,7 +226,7 @@ export class GuidedMetadataPage extends ManagedPage { createForm = ({ subject, session, info }) => { const hasMultipleSessions = this.workflow.multiple_sessions.value; - // const results = createResults({ subject, info }, this.info.globalState); + // const results = createResultsForSession({ subject, info }, this.info.globalState); const { globalState } = this.info; diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js index 1d096586ed..3b9ff99a52 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedPathExpansion.js @@ -3,19 +3,21 @@ import { Page } from "../../Page.js"; // For Multi-Select Form import { JSONSchemaForm, getSchema } from "../../../JSONSchemaForm.js"; -import { run } from "../options/utils.js"; import { onThrow } from "../../../../errors"; import pathExpansionSchema from "../../../../../../../schemas/json/path-expansion.schema.json" assert { type: "json" }; -import { merge } from "../../utils"; import { List } from "../../../List"; -import { fs } from "../../../../../utils/electron.js"; +import { fs } from "../../../../../utils/electron"; import { Button } from "../../../Button.js"; import { Modal } from "../../../Modal"; -import { header } from "../../../forms/utils"; import autocompleteIcon from "../../../../../assets/icons/inspect.svg?raw"; +// Utils +import { header } from "../../../../../utils/text"; +import { merge } from "../../../../../utils/data"; +import { run } from "../../../../../utils/run"; + const propOrder = ["path", "subject_id", "session_id"]; export async function autocompleteFormatString(path) { diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js index 2fd038091b..e989e66524 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedSourceData.js @@ -4,19 +4,19 @@ import { JSONSchemaForm } from "../../../JSONSchemaForm.js"; import { InstanceManager } from "../../../InstanceManager.js"; import { ManagedPage } from "./ManagedPage.js"; import { onThrow } from "../../../../errors"; -import { merge, sanitize } from "../../utils"; +import { merge, sanitize } from "../../../../../utils/data"; import preprocessSourceDataSchema from "../../../../../../../schemas/source-data.schema"; import { TimeAlignment } from "./alignment/TimeAlignment.js"; -import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; -import { header } from "../../../forms/utils"; +import { createGlobalFormModal } from "../../../GlobalFormModal"; +import { header } from "../../../../../utils/text"; import { Button } from "../../../Button.js"; import globalIcon from "../../../../../assets/icons/global.svg?raw"; -import { run } from "../options/utils.js"; -import { getInfoFromId } from "./utils"; +import { run } from "../../../../../utils/run"; +import { getInfoFromId } from "../../../../../utils/data"; import { Modal } from "../../../Modal"; import Swal from "sweetalert2"; diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js index 6bf9f36087..09cd85d489 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/GuidedStructure.js @@ -7,9 +7,8 @@ import { supportedInterfaces } from "../../../../globals"; import { Search } from "../../../Search.js"; import { Modal } from "../../../Modal"; import { List } from "../../../List"; -import { baseUrl } from "../../../../server/globals"; import { ready } from "../../../../../../../schemas/interfaces.info"; -import { run } from "../options/utils.js"; +import { run } from "../../../../../utils/run"; const defaultEmptyMessage = "No formats selected"; diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js index 68feb0db98..3bbb03c23c 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js +++ b/src/electron/frontend/core/components/pages/guided-mode/data/alignment/TimeAlignment.js @@ -1,6 +1,6 @@ import { LitElement, css } from "lit"; import { JSONSchemaInput } from "../../../../JSONSchemaInput"; -import { InspectorListItem } from "../../../../preview/inspector/InspectorList"; +import { InspectorListItem } from "../../../../InspectorList"; const options = { start: { diff --git a/src/electron/frontend/core/components/pages/guided-mode/data/utils.js b/src/electron/frontend/core/components/pages/guided-mode/data/utils.js deleted file mode 100644 index b5259e3bb5..0000000000 --- a/src/electron/frontend/core/components/pages/guided-mode/data/utils.js +++ /dev/null @@ -1,126 +0,0 @@ -import { getEditableItems } from "../../../JSONSchemaInput.js"; -import { merge } from "../../utils.js"; - -// Merge project-wide data into metadata -export function populateWithProjectMetadata(info, globalState) { - const copy = structuredClone(info); - const toMerge = Object.entries(globalState.project).filter(([_, value]) => value && typeof value === "object"); - toMerge.forEach(([key, value]) => { - let internalMetadata = copy[key]; - if (!copy[key]) internalMetadata = copy[key] = {}; - for (let key in value) { - if (!(key in internalMetadata)) internalMetadata[key] = value[key]; // Prioritize existing results (cannot override with new information...) - } - }); - - return copy; -} - -export const getInfoFromId = (key) => { - let [subject, session] = key.split("/"); - if (subject.startsWith("sub-")) subject = subject.slice(4); - if (session.startsWith("ses-")) session = session.slice(4); - - return { subject, session }; -}; - -export function resolveGlobalOverrides(subject, globalState, resolveMultiSessionOverrides = true) { - const subjectMetadataCopy = { ...(globalState.subjects?.[subject] ?? {}) }; - delete subjectMetadataCopy.sessions; // Remove extra key from metadata - - if (resolveMultiSessionOverrides) { - const overrides = structuredClone(globalState.project ?? {}); // Copy project-wide metadata - - merge(subjectMetadataCopy, overrides.Subject ?? (overrides.Subject = {})); // Ensure Subject exists - - return overrides; - } - - return { Subject: subjectMetadataCopy }; -} - -const isPatternResult = Symbol("ispatternresult"); - -export function resolveFromPath(path, target) { - return path.reduce((acc, key) => { - if (!acc) return; - if (acc[isPatternResult]) return acc; - if (key in acc) return acc[key]; - else { - const items = getEditableItems(acc, true, { name: key }); - const object = items.reduce((acc, { key, value }) => (acc[key] = value), {}); - object[isPatternResult] = true; - return object; - } - }, target); -} - -export function drillSchemaProperties(schema = {}, callback, target, path = [], inPatternProperties = false) { - const properties = schema.properties ?? {}; - - const patternProperties = schema.patternProperties ?? {}; - - for (let regexp in patternProperties) { - const info = patternProperties[regexp]; - const updatedPath = [...path, regexp]; - callback(updatedPath, info, undefined, true); - drillSchemaProperties(info, callback, undefined, updatedPath, true, schema); - } - - for (let name in properties) { - const info = properties[name]; - - if (name === "definitions") continue; - - const updatedPath = [...path, name]; - - callback(updatedPath, info, target, undefined, schema); - - drillSchemaProperties(info, callback, target?.[name], updatedPath, inPatternProperties); - } - - return schema; -} - -export function resolveProperties(properties = {}, target, globals = {}) { - if ("properties" in properties && "type" in properties) properties = properties.properties; // Correct for when a schema is passed instead - - for (let name in properties) { - const info = properties[name]; - - const props = info.properties; - - if (!(name in target)) { - if (target.__disabled?.[name]) continue; // Skip disabled properties - - if (props) target[name] = {}; // Regisiter new interfaces in results - // if (info.type === "array") target[name] = []; // Auto-populate arrays (NOTE: Breaks PyNWB when adding to TwoPhotonSeries field...) - - // Apply global or default value if empty - if (name in globals) target[name] = globals[name]; - else if (info.default) target[name] = info.default; - } - - if (target[name]) resolveProperties(props, target[name], globals[name]); - } - - return target; -} - -// Explicitly resolve the results for a particular session (from both GUIDE-defined globals and the NWB Schema) -export function resolveMetadata(subject, session, globalState) { - const overrides = resolveGlobalOverrides(subject, globalState); // Unique per-subject (but not sessions) - const metadata = globalState.results[subject][session].metadata; - const results = structuredClone(metadata); // Copy the metadata results from the form - const schema = globalState.schema.metadata[subject][session]; - resolveProperties(schema, results, overrides); - return results; -} - -export function createResults({ subject, info }, globalState) { - const results = populateWithProjectMetadata(info.metadata, globalState); - const subjectGlobalsCopy = { ...globalState.subjects[subject] }; - delete subjectGlobalsCopy.sessions; // Remove extra key from metadata - merge(subjectGlobalsCopy, results.Subject); - return results; -} 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 80e96c708e..37cd66eba1 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 @@ -4,21 +4,21 @@ import { Page } from "../../Page.js"; import { unsafeSVG } from "lit/directives/unsafe-svg.js"; import folderOpenSVG from "../../../../../assets/icons/folder_open.svg?raw"; -import { electron } from "../../../../../utils/electron.js"; -import { getSharedPath, removeFilePaths, truncateFilePaths } from "../../../preview/NWBFilePreview.js"; +import { electron } from "../../../../../utils/electron"; +import { getSharedPath, removeFilePaths, truncateFilePaths } from "../../../NWBFilePreview.js"; const { ipcRenderer } = electron; import { until } from "lit/directives/until.js"; -import { run } from "./utils.js"; -import { InspectorList, InspectorLegend } from "../../../preview/inspector/InspectorList.js"; +import { run } from "../../../../../utils/run"; +import { InspectorList, InspectorLegend } from "../../../InspectorList.js"; import { getStubArray } from "./GuidedStubPreview.js"; import { InstanceManager } from "../../../InstanceManager.js"; import { getMessageType } from "../../../../validation/index.js"; import { Button } from "../../../Button"; -import { download } from "../../inspect/utils.js"; -import { createProgressPopup } from "../../../utils/progress.js"; -import { resolve } from "../../../../promises"; +import { download } from "../../../../../utils/download"; +import { createProgressPopup } from "../../../../../utils/popups"; +import { resolve } from "../../../../../utils/promises"; const filter = (list, toFilter) => { return list.filter((item) => { diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js index c82d45bc7c..23a2f69085 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js +++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedStubPreview.js @@ -4,8 +4,8 @@ import { Page } from "../../Page.js"; import { unsafeSVG } from "lit/directives/unsafe-svg.js"; import folderOpenSVG from "../../../../../assets/icons/folder_open.svg?raw"; -import { electron } from "../../../../../utils/electron.js"; -import { NWBFilePreview, getSharedPath } from "../../../preview/NWBFilePreview.js"; +import { electron } from "../../../../../utils/electron"; +import { NWBFilePreview, getSharedPath } from "../../../NWBFilePreview.js"; const { ipcRenderer } = electron; export const getStubArray = (stubs) => diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js index 50f400bc8c..0208317bcf 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js +++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js @@ -2,7 +2,7 @@ import { html } from "lit"; import { JSONSchemaForm } from "../../../JSONSchemaForm.js"; import { Page } from "../../Page.js"; import { onThrow } from "../../../../errors"; -import { merge } from "../../utils"; +import { merge } from "../../../../../utils/data"; import Swal from "sweetalert2"; import dandiUploadSchema, { ready, regenerateDandisets } from "../../../../../../../schemas/dandi-upload.schema"; import { createDandiset, uploadToDandi } from "../../uploads/UploadsPage.js"; @@ -12,13 +12,13 @@ import { Button } from "../../../Button.js"; import keyIcon from "../../../../../assets/icons/key.svg?raw"; -import { validate } from "../../uploads/utils"; +import { validate } from "../../../../../utils/upload"; import { global } from "../../../../progress/index.js"; import dandiGlobalSchema from "../../../../../../../schemas/json/dandi/global.json"; -import { createFormModal } from "../../../forms/GlobalFormModal"; +import { createFormModal } from "../../../GlobalFormModal"; import { validateDANDIApiKey } from "../../../../validation/dandi"; -import { resolve } from "../../../../promises"; +import { resolve } from "../../../../../utils/promises"; export class GuidedUploadPage extends Page { constructor(...args) { diff --git a/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js b/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js index b0eb8eafc6..e093a57f1b 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js +++ b/src/electron/frontend/core/components/pages/guided-mode/results/GuidedResults.js @@ -4,9 +4,9 @@ import folderOpenSVG from "../../../../../assets/icons/folder_open.svg?raw"; import { Page } from "../../Page.js"; import { getStubArray } from "../options/GuidedStubPreview.js"; -import { getSharedPath } from "../../../preview/NWBFilePreview.js"; +import { getSharedPath } from "../../../NWBFilePreview.js"; -import { electron, path } from "../../../../../utils/electron.js"; +import { electron, path } from "../../../../../utils/electron"; import manualActionsJSON from "../../../../../../../schemas/json/manual_actions.json"; diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js index 54bb5b12cf..b5c63f7ad5 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js +++ b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedNewDatasetInfo.js @@ -6,9 +6,10 @@ import { validateOnChange } from "../../../../validation/index.js"; import projectGeneralSchema from "../../../../../../../schemas/json/project/general.json" assert { type: "json" }; import projectGlobalSchema from "../../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import { merge } from "../../utils"; import { onThrow } from "../../../../errors"; -import { header } from "../../../forms/utils"; + +import { merge } from "../../../../../utils/data"; +import { header } from "../../../../../utils/text"; const projectMetadataSchema = merge(projectGlobalSchema, projectGeneralSchema); diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js index 26c77c67e6..bf522e7287 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js +++ b/src/electron/frontend/core/components/pages/guided-mode/setup/GuidedSubjects.js @@ -4,11 +4,11 @@ import getSubjectSchema from "../../../../../../../schemas/subject.schema"; import { validateOnChange } from "../../../../validation/index.js"; import { Table } from "../../../Table.js"; -import { updateResultsFromSubjects } from "./utils"; +import { updateResultsFromSubjects } from "../../../../../utils/data"; import { preprocessMetadataSchema } from "../../../../../../../schemas/base-metadata.schema"; import { Button } from "../../../Button.js"; -import { createGlobalFormModal } from "../../../forms/GlobalFormModal"; -import { header } from "../../../forms/utils"; +import { createGlobalFormModal } from "../../../GlobalFormModal"; +import { header } from "../../../../../utils/text"; import globalIcon from "../../../../../assets/icons/global.svg?raw"; diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js b/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js index e608e2dcd9..1b444a3c76 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js +++ b/src/electron/frontend/core/components/pages/guided-mode/setup/Preform.js @@ -2,7 +2,6 @@ import { html } from "lit"; import { JSONSchemaForm } from "../../../JSONSchemaForm.js"; import { Page } from "../../Page.js"; import { onThrow } from "../../../../errors"; -import { merge } from "../../utils.js"; import timezoneSchema from "../../../../../../../schemas/timezone.schema"; // ------------------------------------------------------------------------------ diff --git a/src/electron/frontend/core/components/pages/guided-mode/setup/utils.ts b/src/electron/frontend/core/components/pages/guided-mode/setup/utils.ts deleted file mode 100644 index 63dbe6c1b8..0000000000 --- a/src/electron/frontend/core/components/pages/guided-mode/setup/utils.ts +++ /dev/null @@ -1,48 +0,0 @@ - -export const updateResultsFromSubjects = (results: any, subjects: any, sourceDataObject = {}, nameMap: {[x:string]: string} = {}) => { - - const oldResults = structuredClone(results); - - const toRemove = Object.keys(results).filter((sub) => !Object.keys(subjects).includes(sub)); - for (let sub of toRemove) { - if (sub in nameMap) results[nameMap[sub]] = results[sub]; - delete results[sub]; // Delete extra subjects from results - } - - - for (let subject in subjects) { - const { sessions = [] } = subjects[subject]; - let subObj = results[subject]; - - if (!subObj) subObj = results[subject] = {}; - else { - const toRemove = Object.keys(subObj).filter((s) => !sessions.includes(s)); - for (let s of toRemove) { - - // Skip removal if your data has been mapped - if (subject in nameMap) { - const oldSessionInfo = oldResults[subject] - const newSubResults = results[nameMap[subject]] - if (s in oldSessionInfo) newSubResults[s] = oldSessionInfo[s]; - } - - delete subObj[s]; // Delete extra sessions from results - } - if (!sessions.length && !Object.keys(subObj).length) delete results[subject]; // Delete subjects without sessions - } - - for (let session of sessions) { - if (!(session in subObj)) - subObj[session] = { - source_data: { ...sourceDataObject }, - metadata: { - NWBFile: { session_id: session }, - Subject: { subject_id: subject }, - }, - }; - } - } - - return results - -} diff --git a/src/electron/frontend/core/components/pages/inspect/InspectPage.js b/src/electron/frontend/core/components/pages/inspect/InspectPage.js index 2dea9bb24c..0b645209f9 100644 --- a/src/electron/frontend/core/components/pages/inspect/InspectPage.js +++ b/src/electron/frontend/core/components/pages/inspect/InspectPage.js @@ -3,12 +3,13 @@ import { Page } from "../Page.js"; import { onThrow } from "../../../errors"; import { Button } from "../../Button.js"; -import { run } from "../guided-mode/options/utils.js"; +import { run } from "../../../../utils/run"; import { Modal } from "../../Modal"; -import { getSharedPath, truncateFilePaths } from "../../preview/NWBFilePreview.js"; -import { InspectorList, InspectorLegend } from "../../preview/inspector/InspectorList.js"; -import { download } from "./utils"; -import { createProgressPopup } from "../../utils/progress.js"; +import { getSharedPath, truncateFilePaths } from "../../NWBFilePreview.js"; +import { InspectorList, InspectorLegend } from "../../InspectorList.js"; +import { download } from "../../../../utils/download"; + +import { createProgressPopup } from "../../../../utils/popups"; import { ready } from "../../../../../../schemas/dandi-upload.schema"; import { JSONSchemaForm } from "../../JSONSchemaForm.js"; diff --git a/src/electron/frontend/core/components/pages/inspect/utils.js b/src/electron/frontend/core/components/pages/inspect/utils.js deleted file mode 100644 index 1a96883c6e..0000000000 --- a/src/electron/frontend/core/components/pages/inspect/utils.js +++ /dev/null @@ -1,11 +0,0 @@ -export function download(name, toSave) { - const saveType = typeof toSave === "string" ? "text/plain" : "application/json"; - const text = typeof toSave === "string" ? toSave : JSON.stringify(toSave, null, 2); - const blob = new Blob([text], { type: saveType }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = name; - a.click(); - URL.revokeObjectURL(url); -} diff --git a/src/electron/frontend/core/components/pages/preview/PreviewPage.js b/src/electron/frontend/core/components/pages/preview/PreviewPage.js index e6de3b95ae..8dfe7b4d6f 100644 --- a/src/electron/frontend/core/components/pages/preview/PreviewPage.js +++ b/src/electron/frontend/core/components/pages/preview/PreviewPage.js @@ -2,7 +2,7 @@ import { html } from "lit"; import { Page } from "../Page.js"; import { onThrow } from "../../../errors"; import { JSONSchemaInput } from "../../JSONSchemaInput.js"; -import { Neurosift } from "../../preview/Neurosift.js"; +import { Neurosift } from "../../Neurosift.js"; import { baseUrl } from "../../../server/globals"; export class PreviewPage extends Page { diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 2f67cea669..ae866c70f9 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -10,14 +10,14 @@ import { validateDANDIApiKey } from "../../../validation/dandi"; import { Button } from "../../Button.js"; import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; +import { merge, setUndefinedIfNotDeclared } from "../../../../utils/data"; import { notyf } from "../../../dependencies.js"; import { homeDirectory, testDataFolderPath } from "../../../globals.js"; -import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; +import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron"; -import { onUpdateAvailable, onUpdateProgress } from "../../../../utils/auto-update.js"; +import { onUpdateAvailable, onUpdateProgress } from "../../../../utils/auto-update"; import saveSVG from "../../../../assets/icons/save.svg?raw"; import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; @@ -26,14 +26,14 @@ import generateSVG from "../../../../assets/icons/restart.svg?raw"; import downloadSVG from "../../../../assets/icons/download.svg?raw"; import infoSVG from "../../../../assets/icons/info.svg?raw"; -import { header } from "../../forms/utils"; +import { header } from "../../../../utils/text"; import examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; +import { run } from "../../../../utils/run"; import { joinPath } from "../../../globals"; import { Modal } from "../../Modal"; import { ProgressBar } from "../../ProgressBar"; -import { humanReadableBytes } from "../../utils/size"; +import { humanReadableBytes } from "../../../../utils/bytes"; const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); diff --git a/src/electron/frontend/core/components/pages/uploads/UploadsPage.js b/src/electron/frontend/core/components/pages/uploads/UploadsPage.js index a0be671437..d2762c13a1 100644 --- a/src/electron/frontend/core/components/pages/uploads/UploadsPage.js +++ b/src/electron/frontend/core/components/pages/uploads/UploadsPage.js @@ -14,13 +14,13 @@ import dandiUploadSchema, { import dandiStandaloneSchema from "../../../../../../schemas/json/dandi/standalone.json"; const dandiSchema = merge(dandiUploadSchema, structuredClone(dandiStandaloneSchema), { arrays: "append" }); -import dandiCreateSchema from "../../../../../../schemas/dandi-create.schema"; +import dandiCreateSchema from "../../../../../../schemas/json/dandi/create.json" assert { type: "json" }; import { Button } from "../../Button.js"; import { global } from "../../../progress/index.js"; -import { merge } from "../utils"; +import { merge } from "../../../../utils/data"; -import { run } from "../guided-mode/options/utils.js"; +import { run } from "../../../../utils/run"; import { Modal } from "../../Modal"; import { DandiResults } from "../../DandiResults.js"; @@ -31,8 +31,15 @@ import * as dandi from "dandi"; import keyIcon from "../../../../assets/icons/key.svg?raw"; -import { AWARD_VALIDATION_FAIL_MESSAGE, awardNumberValidator, isStaging, validate, getAPIKey } from "./utils"; -import { createFormModal } from "../../forms/GlobalFormModal"; +import { + AWARD_VALIDATION_FAIL_MESSAGE, + awardNumberValidator, + isStaging, + validate, + getAPIKey, +} from "../../../../utils/upload"; + +import { createFormModal } from "../../GlobalFormModal"; export function createDandiset(results = {}) { let notification; diff --git a/src/electron/frontend/core/components/pages/utils.js b/src/electron/frontend/core/components/pages/utils.js deleted file mode 100644 index 0947b2690f..0000000000 --- a/src/electron/frontend/core/components/pages/utils.js +++ /dev/null @@ -1,77 +0,0 @@ -export const randomizeIndex = (count) => Math.floor(count * Math.random()); - -export const randomizeElements = (array, count) => { - if (count > array.length) throw new Error("Array size cannot be smaller than expected random numbers count."); - const result = []; - const guardian = new Set(); - while (result.length < count) { - const index = randomizeIndex(array.length); - if (guardian.has(index)) continue; - const element = array[index]; - guardian.add(index); - result.push(element); - } - return result; -}; - -const isObject = (item) => { - return item && typeof item === "object" && !Array.isArray(item); -}; - -export const setUndefinedIfNotDeclared = (schemaProps, resolved) => { - if ("properties" in schemaProps) schemaProps = schemaProps.properties; - for (const prop in schemaProps) { - const propInfo = schemaProps[prop]?.properties; - if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]); - else if (!(prop in resolved)) resolved[prop] = undefined; - } -}; - -export const isPrivate = (k) => k.slice(0, 2) === "__"; - -export const sanitize = (item, condition = isPrivate) => { - if (isObject(item)) { - for (const [k, value] of Object.entries(item)) { - if (condition(k, value)) delete item[k]; - else sanitize(value, condition); - } - } else if (Array.isArray(item)) item.forEach((value) => sanitize(value, condition)); - - return item; -}; - -export function merge(toMerge = {}, target = {}, mergeOptions = {}) { - // Deep merge objects - for (const [k, value] of Object.entries(toMerge)) { - const targetValue = target[k]; - // if (isPrivate(k)) continue; - const arrayMergeMethod = mergeOptions.arrays; - if (arrayMergeMethod && Array.isArray(value) && Array.isArray(targetValue)) { - if (arrayMergeMethod === "append") - target[k] = [...targetValue, ...value]; // Append array entries together - else { - target[k] = targetValue.map((targetItem, i) => merge(value[i], targetItem, mergeOptions)); // Merge array entries - } - } else if (value === undefined) { - delete target[k]; // Remove matched values - // if (mergeOptions.remove !== false) delete target[k]; // Remove matched values - } else if (isObject(value)) { - if (isObject(targetValue)) target[k] = merge(value, targetValue, mergeOptions); - else { - if (mergeOptions.clone) - target[k] = merge(value, {}, mergeOptions); // Replace primitive values - else target[k] = value; // Replace object values - } - } else target[k] = value; // Replace primitive values - } - - return target; -} - -export function mapSessions(callback = (value) => value, toIterate = {}) { - return Object.entries(toIterate) - .map(([subject, sessions]) => { - return Object.entries(sessions).map(([session, info], i) => callback({ subject, session, info }, i)); - }) - .flat(2); -} diff --git a/src/electron/frontend/core/components/sidebar.js b/src/electron/frontend/core/components/sidebar.js index 6d79ed6e0a..062a796d4d 100644 --- a/src/electron/frontend/core/components/sidebar.js +++ b/src/electron/frontend/core/components/sidebar.js @@ -1,18 +1,7 @@ import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; -import { header } from "./forms/utils"; - -const componentCSS = ``; // These are not active until the component is using shadow DOM +import { header } from "../../utils/text"; export class Sidebar extends LitElement { - static get styles() { - return useGlobalStyles( - componentCSS, - (sheet) => sheet.href && sheet.href.includes("bootstrap"), - this.shadowRoot - ); - } - static get properties() { return { pages: { type: Object, reflect: false }, diff --git a/src/electron/frontend/core/components/table/cells/base.ts b/src/electron/frontend/core/components/table/cells/base.ts index cf9dbbe37a..19a963b59c 100644 --- a/src/electron/frontend/core/components/table/cells/base.ts +++ b/src/electron/frontend/core/components/table/cells/base.ts @@ -1,5 +1,5 @@ -import { LitElement, PropertyValueMap, css, html } from "lit" -import { placeCaretAtEnd } from "../utils" +import { LitElement, css, html } from "lit" +import { placeCaretAtEnd } from "../../../../utils/table" type BaseTableProps = { info: { diff --git a/src/electron/frontend/core/components/table/cells/input.ts b/src/electron/frontend/core/components/table/cells/input.ts index 4c02b508de..9911c52016 100644 --- a/src/electron/frontend/core/components/table/cells/input.ts +++ b/src/electron/frontend/core/components/table/cells/input.ts @@ -6,7 +6,7 @@ import { Modal } from "../../Modal.js"; import { SimpleTable } from "../../SimpleTable.js"; import { JSONSchemaInput } from "../../JSONSchemaInput.js"; -import { header } from "../../forms/utils.js"; +import { header } from "../../../../utils/text"; export class NestedEditor extends LitElement { diff --git a/src/electron/frontend/core/components/table/utils.ts b/src/electron/frontend/core/components/table/utils.ts deleted file mode 100644 index 9a851f902f..0000000000 --- a/src/electron/frontend/core/components/table/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ - - -export function placeCaretAtEnd(inputElement) { - inputElement.focus(); - if (typeof window.getSelection != "undefined" && typeof document.createRange != "undefined") { - var range = document.createRange(); - range.selectNodeContents(inputElement); - range.collapse(false); - var selection = window.getSelection(); - selection.removeAllRanges(); - selection.addRange(range); - } else if (typeof document.body.createTextRange != "undefined") { - var textRange = document.body.createTextRange(); - textRange.moveToElementText(inputElement); - textRange.collapse(false); - textRange.select(); - } -} diff --git a/src/electron/frontend/core/components/utils/useGlobalStyles.js b/src/electron/frontend/core/components/utils/useGlobalStyles.js deleted file mode 100644 index addc13601a..0000000000 --- a/src/electron/frontend/core/components/utils/useGlobalStyles.js +++ /dev/null @@ -1,16 +0,0 @@ -import { css } from "lit"; - -const useGlobalStyles = (componentCSS, condition, toApply = true) => { - if (!toApply || !condition) return css([componentCSS]); - - const sheets = Object.values(document.styleSheets); - const selectedSheets = condition instanceof Function ? sheets.filter(condition) : sheets; - const rules = selectedSheets.map((sheet) => - Object.values(sheet.cssRules) - .map((rule) => rule.cssText) - .join("\n") - ); - return css([componentCSS, ...rules]); -}; - -export default useGlobalStyles; diff --git a/src/electron/frontend/core/errors.ts b/src/electron/frontend/core/errors.ts index ba1794a915..8c64621d12 100644 --- a/src/electron/frontend/core/errors.ts +++ b/src/electron/frontend/core/errors.ts @@ -1,5 +1,2 @@ import { notify } from './dependencies' - -export const onThrow = (message: string, id?: string) => { - return notify(id ? `[${id}]: ${message}` : message, "error", 7000); -} +export const onThrow = (message: string, id?: string) => notify(id ? `[${id}]: ${message}` : message, "error", 7000); diff --git a/src/electron/frontend/core/globals.js b/src/electron/frontend/core/globals.js index 0ca85c1a23..8bebbaaf29 100644 --- a/src/electron/frontend/core/globals.js +++ b/src/electron/frontend/core/globals.js @@ -1,4 +1,4 @@ -import { os, path, crypto, isElectron, isTestEnvironment } from "../utils/electron.js"; +import { os, path, crypto, isElectron, isTestEnvironment } from "../utils/electron"; import paths from "../../../paths.config.json" assert { type: "json" }; diff --git a/src/electron/frontend/core/index.ts b/src/electron/frontend/core/index.ts index c7d0a48973..815094fc84 100644 --- a/src/electron/frontend/core/index.ts +++ b/src/electron/frontend/core/index.ts @@ -1,5 +1,5 @@ import "./pages.js" -import { isElectron, electron } from '../utils/electron.js' +import { isElectron, electron } from '../utils/electron' import { isTestEnvironment } from './globals.js' const { ipcRenderer } = electron; diff --git a/src/electron/frontend/core/progress/index.js b/src/electron/frontend/core/progress/index.js index 617ebf6086..eeb945ba56 100644 --- a/src/electron/frontend/core/progress/index.js +++ b/src/electron/frontend/core/progress/index.js @@ -9,12 +9,12 @@ import { ENCRYPTION_IV, } from "../globals.js"; -import { fs, crypto } from "../../utils/electron.js"; +import { fs, crypto } from "../../utils/electron"; import { joinPath, runOnLoad } from "../globals"; -import { merge } from "../components/pages/utils.js"; +import { merge } from "../../utils/data"; import { updateAppProgress, updateFile } from "./update.js"; -import { updateURLParams } from "../../utils/url.js"; +import { updateURLParams } from "../../utils/url"; import * as operations from "./operations.js"; diff --git a/src/electron/frontend/core/progress/update.js b/src/electron/frontend/core/progress/update.js index 1b73bbc0e7..4fb6e506f1 100644 --- a/src/electron/frontend/core/progress/update.js +++ b/src/electron/frontend/core/progress/update.js @@ -1,6 +1,6 @@ -import { updateURLParams } from "../../utils/url.js"; +import { updateURLParams } from "../../utils/url"; import { guidedProgressFilePath } from "../globals.js"; -import { fs } from "../../utils/electron.js"; +import { fs } from "../../utils/electron"; import { joinPath } from "../globals"; import { get, hasEntry } from "./index.js"; diff --git a/src/electron/frontend/core/server/globals.ts b/src/electron/frontend/core/server/globals.ts index 6f23ea2c7b..55f1326676 100644 --- a/src/electron/frontend/core/server/globals.ts +++ b/src/electron/frontend/core/server/globals.ts @@ -1,27 +1,15 @@ -import { isElectron, app, port } from '../../utils/electron.js' +import { isElectron, app, port } from '../../utils/electron' import serverSVG from "../../assets/icons/server.svg?raw"; import webAssetSVG from "../../assets/icons/web_asset.svg?raw"; import wifiSVG from "../../assets/icons/wifi.svg?raw"; +import { StatusBar } from "../components/StatusBar.js"; +import { unsafeSVG } from "lit/directives/unsafe-svg.js"; + // Base Request URL for Python Server export const baseUrl = `http://127.0.0.1:${port}`; -const isPromise = (object) => object && typeof object === 'object' && typeof object.then === 'function' - -export const resolve = (object, callback) => { - if (isPromise(object)) { - return new Promise(resolvePromise => { - object.then((res) => resolvePromise((callback) ? callback(res) : res)) - }) - } else return (callback) ? callback(object) : object -} - -// ------------------------------------------------- - -import { StatusBar } from "../components/status/StatusBar.js"; -import { unsafeSVG } from "lit/directives/unsafe-svg.js"; - const appVersion = app?.getVersion(); export const statusBar = new StatusBar({ diff --git a/src/electron/frontend/core/server/index.ts b/src/electron/frontend/core/server/index.ts index aecc5b3f18..89edce13a7 100644 --- a/src/electron/frontend/core/server/index.ts +++ b/src/electron/frontend/core/server/index.ts @@ -1,4 +1,4 @@ -import { isElectron, electron, app } from '../../utils/electron.js' +import { isElectron, electron, app } from '../../utils/electron' const { ipcRenderer } = electron; import { isTestEnvironment } from '../globals.js' diff --git a/src/electron/frontend/core/validation/backend-configuration.ts b/src/electron/frontend/core/validation/backend-configuration.ts index 16431dc93c..b93250634d 100644 --- a/src/electron/frontend/core/validation/backend-configuration.ts +++ b/src/electron/frontend/core/validation/backend-configuration.ts @@ -1,4 +1,4 @@ -import { humanReadableBytes } from "../components/utils/size"; +import { humanReadableBytes } from "../../utils/bytes"; const prod = (arr: number[]) => arr.reduce((accumulator, currentValue) => accumulator * currentValue, 1); diff --git a/src/electron/frontend/core/validation/index.js b/src/electron/frontend/core/validation/index.js index b7311e7814..e5561ac8da 100644 --- a/src/electron/frontend/core/validation/index.js +++ b/src/electron/frontend/core/validation/index.js @@ -1,4 +1,4 @@ -import { resolveAll } from "../promises"; +import { resolveAll } from "../../utils/promises"; import { baseUrl } from "../server/globals"; import validationSchema from "./validation"; diff --git a/src/electron/frontend/utils/auto-update.js b/src/electron/frontend/utils/auto-update.ts similarity index 100% rename from src/electron/frontend/utils/auto-update.js rename to src/electron/frontend/utils/auto-update.ts diff --git a/src/electron/frontend/core/components/utils/size.ts b/src/electron/frontend/utils/bytes.ts similarity index 90% rename from src/electron/frontend/core/components/utils/size.ts rename to src/electron/frontend/utils/bytes.ts index 2e08881342..18304dbab0 100644 --- a/src/electron/frontend/core/components/utils/size.ts +++ b/src/electron/frontend/utils/bytes.ts @@ -7,7 +7,7 @@ export function humanReadableBytes(size: number | string) { let index = 0; // Convert the size to a floating point number - size = parseFloat(size); + size = typeof size === 'string' ? parseFloat(size) : size; // Loop until the size is less than 1024 and increment the unit while (size >= 1000 && index < units.length - 1) { diff --git a/src/electron/frontend/utils/data.ts b/src/electron/frontend/utils/data.ts new file mode 100644 index 0000000000..730a881a17 --- /dev/null +++ b/src/electron/frontend/utils/data.ts @@ -0,0 +1,290 @@ +import { isObject } from "./typecheck.js"; + +type Schema = { properties: Record }; + +type ConditionFunction = (key: string, value: any) => boolean; + +type Object = Record; + +type MergeOptions = { + arrays?: "append" | "merge"; + clone?: boolean; + remove?: boolean; +}; + +// Merge project-wide data into metadata +export function populateWithProjectMetadata(info, globalState) { + const copy = structuredClone(info); + const toMerge = Object.entries(globalState.project).filter(([_, value]) => value && typeof value === "object"); + toMerge.forEach(([key, value]) => { + let internalMetadata = copy[key]; + if (!copy[key]) internalMetadata = copy[key] = {}; + for (let key in value) { + if (!(key in internalMetadata)) internalMetadata[key] = value[key]; // Prioritize existing results (cannot override with new information...) + } + }); + + return copy; +} + +export const getInfoFromId = (key) => { + let [subject, session] = key.split("/"); + if (subject.startsWith("sub-")) subject = subject.slice(4); + if (session.startsWith("ses-")) session = session.slice(4); + + return { subject, session }; +}; + +export function resolveGlobalOverrides(subject, globalState, resolveMultiSessionOverrides = true) { + const subjectMetadataCopy = { ...(globalState.subjects?.[subject] ?? {}) }; + delete subjectMetadataCopy.sessions; // Remove extra key from metadata + + if (resolveMultiSessionOverrides) { + const overrides = structuredClone(globalState.project ?? {}); // Copy project-wide metadata + + merge(subjectMetadataCopy, overrides.Subject ?? (overrides.Subject = {})); // Ensure Subject exists + + return overrides; + } + + return { Subject: subjectMetadataCopy }; +} + +export function drillSchemaProperties(schema = {}, callback, target, path = [], inPatternProperties = false) { + const properties = schema.properties ?? {}; + + const patternProperties = schema.patternProperties ?? {}; + + for (let regexp in patternProperties) { + const info = patternProperties[regexp]; + const updatedPath = [...path, regexp]; + callback(updatedPath, info, undefined, true); + drillSchemaProperties(info, callback, undefined, updatedPath, true, schema); + } + + for (let name in properties) { + const info = properties[name]; + + if (name === "definitions") continue; + + const updatedPath = [...path, name]; + + callback(updatedPath, info, target, undefined, schema); + + drillSchemaProperties(info, callback, target?.[name], updatedPath, inPatternProperties); + } + + return schema; +} + +export function resolveProperties(properties = {}, target, globals = {}) { + if ("properties" in properties && "type" in properties) properties = properties.properties; // Correct for when a schema is passed instead + + for (let name in properties) { + const info = properties[name]; + + const props = info.properties; + + if (!(name in target)) { + if (target.__disabled?.[name]) continue; // Skip disabled properties + + if (props) target[name] = {}; // Regisiter new interfaces in results + // if (info.type === "array") target[name] = []; // Auto-populate arrays (NOTE: Breaks PyNWB when adding to TwoPhotonSeries field...) + + // Apply global or default value if empty + if (name in globals) target[name] = globals[name]; + else if (info.default) target[name] = info.default; + } + + if (target[name]) resolveProperties(props, target[name], globals[name]); + } + + return target; +} + +// Explicitly resolve the results for a particular session (from both GUIDE-defined globals and the NWB Schema) +export function resolveMetadata(subject, session, globalState) { + const overrides = resolveGlobalOverrides(subject, globalState); // Unique per-subject (but not sessions) + const metadata = globalState.results[subject][session].metadata; + const results = structuredClone(metadata); // Copy the metadata results from the form + const schema = globalState.schema.metadata[subject][session]; + resolveProperties(schema, results, overrides); + return results; +} + +export function createResultsForSession({ subject, info }, globalState) { + const results = populateWithProjectMetadata(info.metadata, globalState); + const subjectGlobalsCopy = { ...globalState.subjects[subject] }; + delete subjectGlobalsCopy.sessions; // Remove extra key from metadata + results.Subject = merge(subjectGlobalsCopy, results.Subject); + return results; +} + + + +export const updateResultsFromSubjects = (results: any, subjects: any, sourceDataObject = {}, nameMap: {[x:string]: string} = {}) => { + + const oldResults = structuredClone(results); + + const toRemove = Object.keys(results).filter((sub) => !Object.keys(subjects).includes(sub)); + for (let sub of toRemove) { + if (sub in nameMap) results[nameMap[sub]] = results[sub]; + delete results[sub]; // Delete extra subjects from results + } + + + for (let subject in subjects) { + const { sessions = [] } = subjects[subject]; + let subObj = results[subject]; + + if (!subObj) subObj = results[subject] = {}; + else { + const toRemove = Object.keys(subObj).filter((s) => !sessions.includes(s)); + for (let s of toRemove) { + + // Skip removal if your data has been mapped + if (subject in nameMap) { + const oldSessionInfo = oldResults[subject] + const newSubResults = results[nameMap[subject]] + if (s in oldSessionInfo) newSubResults[s] = oldSessionInfo[s]; + } + + delete subObj[s]; // Delete extra sessions from results + } + if (!sessions.length && !Object.keys(subObj).length) delete results[subject]; // Delete subjects without sessions + } + + for (let session of sessions) { + if (!(session in subObj)) + subObj[session] = { + source_data: { ...sourceDataObject }, + metadata: { + NWBFile: { session_id: session }, + Subject: { subject_id: subject }, + }, + }; + } + } + + return results + +} + +export const setUndefinedIfNotDeclared = ( + schemaProps: Schema | Schema["properties"], + resolved: Record = {} +) => { + + const resolvedProps = "properties" in schemaProps ? schemaProps.properties : schemaProps; + + for (const prop in resolvedProps) { + const propInfo = resolvedProps[prop]?.properties; + if (propInfo) setUndefinedIfNotDeclared(propInfo, resolved[prop]); + else if (!(prop in resolved)) resolved[prop] = undefined; + } +}; + +const isPrivate = (k: string) => k.slice(0, 2) === "__"; + +export const sanitize = ( + item: any, + condition: ConditionFunction = isPrivate +) => { + if (isObject(item)) { + for (const [k, value] of Object.entries(item)) { + if (condition(k, value)) delete item[k]; + else sanitize(value, condition); + } + } else if (Array.isArray(item)) item.forEach((value) => sanitize(value, condition)); + + return item; +}; + +export function merge( + toMerge: Object = {}, + target: Object = {}, + mergeOptions: MergeOptions = {} +) { + // Deep merge objects + for (const [k, value] of Object.entries(toMerge)) { + const targetValue = target[k]; + // if (isPrivate(k)) continue; + const arrayMergeMethod = mergeOptions.arrays; + if (arrayMergeMethod && Array.isArray(value) && Array.isArray(targetValue)) { + if (arrayMergeMethod === "append") + target[k] = [...targetValue, ...value]; // Append array entries together + else { + target[k] = targetValue.map((targetItem, i) => merge(value[i], targetItem, mergeOptions)); // Merge array entries + } + } else if (value === undefined) { + delete target[k]; // Remove matched values + // if (mergeOptions.remove !== false) delete target[k]; // Remove matched values + } else if (isObject(value)) { + if (isObject(targetValue)) target[k] = merge(value, targetValue, mergeOptions); + else { + if (mergeOptions.clone) + target[k] = merge(value, {}, mergeOptions); // Replace primitive values + else target[k] = value; // Replace object values + } + } else target[k] = value; // Replace primitive values + } + + return target; +} + +export function mapSessions(callback = (value) => value, toIterate = {}) { + return Object.entries(toIterate) + .map(([subject, sessions]) => { + return Object.entries(sessions).map(([session, info], i) => callback({ subject, session, info }, i)); + }) + .flat(2); +} + + +export const resolveAsJSONSchema = ( + schema: Schema, + path: string[] = [], + parent: { [x:string]: any } = structuredClone(schema) +) => { + + const copy = { ...schema } + + if (isObject(schema)) { + + const resolvedProps = copy // "properties" in copy ? copy.properties : copy; + + for (let propName in resolvedProps) { + const prop = resolvedProps[propName]; + + if (isObject(prop)) { + const internalCopy = (resolvedProps[propName] = { ...prop }); + const refValue = internalCopy["$ref"] + const allOfValue = internalCopy['allOf'] + + if (allOfValue) { + resolvedProps[propName]= allOfValue.reduce((acc, curr) => { + const result = resolveAsJSONSchema({ _temp: curr}, path, parent) + const resolved = result._temp + return merge(resolved, acc) + }, {}) + } + + else if (refValue) { + + const refPath = refValue.split('/').slice(1) // NOTE: Assume from base + const resolved = refPath.reduce((acc, key) => acc[key], parent) + + if (resolved) resolvedProps[propName] = resolved; + else delete resolvedProps[propName] + } + + // Find refs on any level of an object + else resolvedProps[propName] = resolveAsJSONSchema(internalCopy, [...path, propName], parent); + } + } + + return copy as { [x:string]: any } + } + + return schema; +} diff --git a/src/electron/frontend/utils/download.ts b/src/electron/frontend/utils/download.ts new file mode 100644 index 0000000000..364e6ede27 --- /dev/null +++ b/src/electron/frontend/utils/download.ts @@ -0,0 +1,14 @@ +export function download( + name: string, + body: string | object +) { + const saveType = typeof body === "string" ? "text/plain" : "application/json"; + const text = typeof body === "string" ? body : JSON.stringify(body, null, 2); + const blob = new Blob([text], { type: saveType }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = name; + a.click(); + URL.revokeObjectURL(url); +} diff --git a/src/electron/frontend/utils/electron.js b/src/electron/frontend/utils/electron.ts similarity index 95% rename from src/electron/frontend/utils/electron.js rename to src/electron/frontend/utils/electron.ts index 8531cfdb0a..557475666e 100644 --- a/src/electron/frontend/utils/electron.js +++ b/src/electron/frontend/utils/electron.ts @@ -1,5 +1,5 @@ -import { registerUpdate, registerUpdateProgress } from "./auto-update.js"; -import { updateURLParams } from "./url.js"; +import { registerUpdate, registerUpdateProgress } from "./auto-update"; +import { updateURLParams } from "./url"; export const isTestEnvironment = globalThis?.process?.env?.VITEST; diff --git a/src/electron/frontend/utils/forms.ts b/src/electron/frontend/utils/forms.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/electron/frontend/core/components/utils/progress.js b/src/electron/frontend/utils/popups.ts similarity index 58% rename from src/electron/frontend/core/components/utils/progress.js rename to src/electron/frontend/utils/popups.ts index 9d525057ac..396e2ccc8c 100644 --- a/src/electron/frontend/core/components/utils/progress.js +++ b/src/electron/frontend/utils/popups.ts @@ -1,120 +1,123 @@ -import { openProgressSwal } from "../pages/guided-mode/options/utils.js"; -import { ProgressBar } from "../ProgressBar"; -import { baseUrl } from "../../server/globals"; -import { createRandomString } from "../forms/utils"; - -export const createProgressPopup = async (options, tqdmCallback) => { - const cancelController = new AbortController(); - - if (!("showCancelButton" in options)) { - options.showCancelButton = true; - options.customClass = { actions: "swal-conversion-actions" }; - } - - const popup = await openProgressSwal(options, (result) => { - if (!result.isConfirmed) cancelController.abort(); - }); - - let elements = {}; - popup.hideLoading(); - const element = (elements.container = popup.getHtmlContainer()); - element.innerText = ""; - - Object.assign(element.style, { - marginTop: "5px", - }); - - const container = document.createElement("div"); - Object.assign(container.style, { - textAlign: "left", - display: "flex", - flexDirection: "column", - overflow: "hidden", - width: "100%", - gap: "5px", - }); - element.append(container); - - const bars = {}; - - const getBar = (id, large = false) => { - if (!bars[id]) { - const bar = new ProgressBar({ size: large ? undefined : "small" }); - bars[id] = bar; - container.append(bar); - } - return bars[id]; - }; - - const globalSymbol = Symbol("global"); - - elements.progress = getBar(globalSymbol, true); - - elements.bars = bars; - - const commonReturnValue = { swal: popup, fetch: { signal: cancelController.signal }, elements, ...options }; - - // Provide a default callback - let lastUpdate; - - const id = createRandomString(); - - const onProgressMessage = ({ data }) => { - const parsed = JSON.parse(data); - const { request_id, ...update } = parsed; - console.warn("parsed", parsed); - - if (request_id && request_id !== id) return; - lastUpdate = Date.now(); - - const _barId = parsed.progress_bar_id; - const barId = id === _barId ? globalSymbol : _barId; - const bar = getBar(barId); - if (!tqdmCallback) bar.format = parsed.format_dict; - else tqdmCallback(update); - }; - - progressHandler.addEventListener("message", onProgressMessage); - - const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - - const close = async () => { - if (lastUpdate) { - // const timeSinceLastUpdate = now - lastUpdate; - const animationLeft = 1000; // ProgressBar.animationDuration - timeSinceLastUpdate; // Add 100ms to ensure the animation has time to complete - if (animationLeft) await sleep(animationLeft); - } - - popup.close(); - - progressHandler.removeEventListener("message", onProgressMessage); - }; - - return { ...commonReturnValue, id, close }; -}; - -const progressEventsUrl = new URL("/neuroconv/events/progress", baseUrl).href; - -class ProgressHandler { - constructor(props) { - const { url, callbacks, ...otherProps } = props; - - const source = (this.source = new EventSource(url)); - Object.assign(this, otherProps); - - source.addEventListener("error", this.onerror(), false); - - source.addEventListener("open", () => this.onopen(), false); - - source.addEventListener("message", (event) => this.onmessage(event), false); - } - - onopen = () => {}; - onmessage = () => {}; - onerror = () => {}; - - addEventListener = (...args) => this.source.addEventListener(...args); - removeEventListener = (...args) => this.source.removeEventListener(...args); -} - -export const progressHandler = new ProgressHandler({ url: progressEventsUrl }); + +import Swal, { SweetAlertOptions } from "sweetalert2"; +import { ProgressBar } from "../core/components/ProgressBar"; +import { getRandomString } from "./random"; + +import progressHandler from "./progress"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +export const openProgressSwal = ( + options: SweetAlertOptions, + callback: (result: any) => void +): Promise => { + return new Promise((resolve) => { + Swal.fire({ + title: "Requesting data from server", + allowEscapeKey: false, + allowOutsideClick: false, + showConfirmButton: false, + heightAuto: false, + backdrop: "rgba(0,0,0, 0.4)", + timerProgressBar: false, + didOpen: () => { + Swal.showLoading(); + resolve(true); + }, + ...options, + }).then((result) => callback?.(result)); + }); +}; + +export const createProgressPopup = async ( + options: SweetAlertOptions, + tqdmCallback: (update: any) => void +) => { + const cancelController = new AbortController(); + + if (!("showCancelButton" in options)) { + options.showCancelButton = true; + options.customClass = { actions: "swal-conversion-actions" }; + } + + const popup = await openProgressSwal(options, (result) => { + if (!result.isConfirmed) cancelController.abort(); + }); + + let elements: Record> = {}; + Swal.hideLoading(); + const element = elements.container = Swal.getHtmlContainer()!; + element.innerText = ""; + + Object.assign(element.style, { + marginTop: "5px", + }); + + const container = document.createElement("div"); + Object.assign(container.style, { + textAlign: "left", + display: "flex", + flexDirection: "column", + overflow: "hidden", + width: "100%", + gap: "5px", + }); + element.append(container); + + const bars: Record = {}; + + const getBar = ( + id: string | symbol, + large = false + ) => { + if (!bars[id]) { + const bar = new ProgressBar({ size: large ? undefined : "small" }); + bars[id] = bar; + container.append(bar); + } + return bars[id]; + }; + + const globalSymbol = Symbol("global"); + + elements.progress = getBar(globalSymbol, true); + + elements.bars = bars; + + const commonReturnValue = { swal: popup, fetch: { signal: cancelController.signal }, elements, ...options }; + + // Provide a default callback + let lastUpdate: number; + + const id = getRandomString(); + + const onProgressMessage = ({ data }) => { + const parsed = JSON.parse(data); + const { request_id, ...update } = parsed; + + if (request_id && request_id !== id) return; + lastUpdate = Date.now(); + + const _barId = parsed.progress_bar_id; + const barId = id === _barId ? globalSymbol : _barId; + const bar = getBar(barId); + if (!tqdmCallback) bar.format = parsed.format_dict; + else tqdmCallback(update); + }; + + progressHandler.addEventListener("message", onProgressMessage); + + const close = async () => { + if (lastUpdate) { + // const timeSinceLastUpdate = now - lastUpdate; + const animationLeft = 1000; // ProgressBar.animationDuration - timeSinceLastUpdate; // Add 100ms to ensure the animation has time to complete + if (animationLeft) await sleep(animationLeft); + } + + Swal.close(); + + progressHandler.removeEventListener("message", onProgressMessage); + }; + + return { ...commonReturnValue, id, close }; +}; diff --git a/src/electron/frontend/utils/progress.ts b/src/electron/frontend/utils/progress.ts new file mode 100644 index 0000000000..c03c65fbcd --- /dev/null +++ b/src/electron/frontend/utils/progress.ts @@ -0,0 +1,43 @@ +import { baseUrl } from "../core/server/globals"; + +const progressEventsUrl = new URL("/neuroconv/events/progress", baseUrl).href; + +type OnOpenCallback = () => void; +type OnMessageCallback = (event: MessageEvent) => void; +type OnErrorCallback = (event: Event) => void; + +type ProgressHandlerProps = { + url: string; + onopen?: OnOpenCallback; + onmessage?: OnMessageCallback; + onerror?: OnErrorCallback; +} + +class ProgressHandler { + + source: EventSource; + + onopen: OnOpenCallback = () => {}; + onmessage: OnMessageCallback = () => {}; + onerror: OnErrorCallback = () => {}; + + constructor(props: ProgressHandlerProps) { + const { url, ...otherProps } = props; + + const source = (this.source = new EventSource(url)); + Object.assign(this, otherProps); + + source.addEventListener("error", this.onerror, false); + + source.addEventListener("open", () => this.onopen(), false); + + source.addEventListener("message", this.onmessage, false); + } + + addEventListener = (type: string, listener: any, options?: any) => this.source.addEventListener(type, listener, options); + removeEventListener = (type: string, listener: any, options?: any) => this.source.removeEventListener(type, listener, options); +} + +// Create a single global instance of ProgressHandler +const progressHandler = new ProgressHandler({ url: progressEventsUrl }); +export default progressHandler diff --git a/src/electron/frontend/core/promises.ts b/src/electron/frontend/utils/promises.ts similarity index 81% rename from src/electron/frontend/core/promises.ts rename to src/electron/frontend/utils/promises.ts index be290c0578..736d34ce54 100644 --- a/src/electron/frontend/core/promises.ts +++ b/src/electron/frontend/utils/promises.ts @@ -4,6 +4,6 @@ type ResolveCallback = (object: any, wasPromise?: true) => any export const isPromise = (p: PossiblePromise) => typeof p === 'object' && typeof p.then === 'function' -export const resolve = (object: PossiblePromise, callback: ResolveCallback) => (isPromise(object)) ? object.then((value:any) => callback(value, true)) : callback(object) +export const resolve = (object: PossiblePromise, callback: ResolveCallback) => isPromise(object) ? object.then((value:any) => callback(value, true)) : callback(object) export const resolveAll = (arr: PossiblePromise[], callback: ResolveCallback) => arr.find(object => isPromise(object)) ? Promise.all(arr).then((arr:any[]) => callback(arr, true)) : callback(arr) diff --git a/src/electron/frontend/utils/random.ts b/src/electron/frontend/utils/random.ts new file mode 100644 index 0000000000..04fcbc60e2 --- /dev/null +++ b/src/electron/frontend/utils/random.ts @@ -0,0 +1,21 @@ + +export const getRandomIndex = (count: number) => Math.floor(count * Math.random()); + +export const getRandomString = () => Math.random().toString(36).substring(7); + +export const getRandomSample = ( + array: any[], + count: number +) => { + if (count > array.length) throw new Error("Array size cannot be smaller than expected random numbers count."); + const result = []; + const guardian = new Set(); + while (result.length < count) { + const index = getRandomIndex(array.length); + if (guardian.has(index)) continue; + const element = array[index]; + guardian.add(index); + result.push(element); + } + return result; +}; diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/utils.js b/src/electron/frontend/utils/run.ts similarity index 56% rename from src/electron/frontend/core/components/pages/guided-mode/options/utils.js rename to src/electron/frontend/utils/run.ts index 7e6e8bc439..288d8f91ef 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/options/utils.js +++ b/src/electron/frontend/utils/run.ts @@ -1,28 +1,23 @@ -import Swal from "sweetalert2"; -import { sanitize } from "../../utils"; -import { baseUrl } from "../../../../server/globals"; - -export const openProgressSwal = (options, callback) => { - return new Promise((resolve) => { - Swal.fire({ - title: "Requesting data from server", - allowEscapeKey: false, - allowOutsideClick: false, - showConfirmButton: false, - heightAuto: false, - backdrop: "rgba(0,0,0, 0.4)", - timerProgressBar: false, - didOpen: () => { - Swal.showLoading(); - resolve(Swal); - }, - ...options, - }).then((result) => callback?.(result)); - }); -}; +import Swal, { SweetAlertOptions } from "sweetalert2"; +import { sanitize } from "./data"; +import { baseUrl } from "../core/server/globals"; +import { openProgressSwal } from "./popups"; + +type Options = { + swal?: boolean; + fetch?: any; + onOpen?: (swal: any) => void; +} & SweetAlertOptions + +type PayloadType = Record; -export const run = async (pathname, payload, options = {}) => { - let internalSwal; +export const run = async ( + pathname: string, + payload: PayloadType, + options: Options = {} +) => { + + let internalSwal = false; if (options.swal === false) { } else if (!options.swal || options.swal === true) { @@ -37,17 +32,17 @@ export const run = async (pathname, payload, options = {}) => { signal: cancelController.signal, }; - const popup = (internalSwal = await openProgressSwal(options, (result) => { + internalSwal = await openProgressSwal(options, (result) => { if (!result.isConfirmed) cancelController.abort(); }).then(async (swal) => { if (options.onOpen) await options.onOpen(swal); return swal; - })); + }); - const element = popup.getHtmlContainer(); + const element = Swal.getHtmlContainer()!; - const actions = popup.getActions(); - const loader = actions.querySelector(".swal2-loader"); + const actions = Swal.getActions()!; + const loader = actions.querySelector(".swal2-loader")!; const container = document.createElement("div"); container.append(loader); @@ -99,15 +94,10 @@ export const run = async (pathname, payload, options = {}) => { return results || true; }; -export const runConversion = async (info, options = {}) => - run(`neuroconv/convert`, info, { - title: "Running the conversion", - onError: (error) => { - if (error.message.includes("already exists")) { - return "File already exists. Please specify another location to store the conversion results"; - } else { - return "Conversion failed with current metadata. Please try again."; - } - }, - ...options, - }); +export const runConversion = async ( + info: PayloadType, + options = {} +) => run(`neuroconv/convert`, info, { + title: "Running the conversion", + ...options, +}); diff --git a/src/electron/frontend/utils/table.ts b/src/electron/frontend/utils/table.ts new file mode 100644 index 0000000000..d12899c769 --- /dev/null +++ b/src/electron/frontend/utils/table.ts @@ -0,0 +1,17 @@ + + +// When clicking into a contenteditable div, the cursor is, by default, placed at the beginning of the text. +// This function places the cursor at the end of the text. +export function placeCaretAtEnd( + inputElement: HTMLInputElement +) { + inputElement.focus(); + const range = document.createRange(); + range.selectNodeContents(inputElement); + range.collapse(false); + const selection = window.getSelection(); + if (!selection) return; + selection.removeAllRanges(); + selection.addRange(range); + +} diff --git a/src/electron/frontend/utils/text.ts b/src/electron/frontend/utils/text.ts new file mode 100644 index 0000000000..0f955bb91d --- /dev/null +++ b/src/electron/frontend/utils/text.ts @@ -0,0 +1,10 @@ +const toCapitalizeAll = ['nwb', 'api', 'id'] +const toCapitalizeNone = ['or', 'and'] + +export const capitalize = (str: string) => { + const lowerCase = str.toLowerCase() + return toCapitalizeAll.includes(lowerCase) ? str.toUpperCase() : (toCapitalizeNone.includes(lowerCase) ? lowerCase : str[0].toUpperCase() + str.slice(1)) +} + + +export const header = (headerStr: string) => headerStr.split(/[_\s]/).filter(str => !!str).map(capitalize).join(' ') diff --git a/src/electron/frontend/utils/time.ts b/src/electron/frontend/utils/time.ts new file mode 100644 index 0000000000..3b616236cc --- /dev/null +++ b/src/electron/frontend/utils/time.ts @@ -0,0 +1,36 @@ +import { localTimeZone } from "../../../schemas/timezone.schema"; + +export const getTimezoneOffset = ( + date = new Date(), + timezone = localTimeZone +) => { + + if (typeof date === 'string') date = new Date(date) + + const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })); + const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); + return utcDate.getTime() - tzDate.getTime(); +} + +export const formatTimezoneOffset = ( + milliseconds: number +) => { + let offsetInMinutes = -((milliseconds / 1000) / 60); // getTimezoneOffset returns the difference in minutes from UTC + const sign = offsetInMinutes >= 0 ? "+" : "-"; + offsetInMinutes = Math.abs(offsetInMinutes); + const hours = String(Math.floor(offsetInMinutes / 60)).padStart(2, "0"); + const minutes = String(offsetInMinutes % 60).padStart(2, "0"); + return `${sign}${hours}:${minutes}`; +} + +export function getISODateInTimezone( + date = new Date(), + timezone = localTimeZone +) { + + if (typeof date === 'string') date = new Date(date) + + const offset = getTimezoneOffset(date, timezone) + const adjustedDate = new Date(date.getTime() - offset); + return adjustedDate.toISOString(); +} diff --git a/src/electron/frontend/utils/typecheck.ts b/src/electron/frontend/utils/typecheck.ts new file mode 100644 index 0000000000..2b0e3faf0e --- /dev/null +++ b/src/electron/frontend/utils/typecheck.ts @@ -0,0 +1,9 @@ +export function isNumericString(str: any) { + + if (typeof str != "string") return false // we only process strings! + + return !isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... + !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail + } + +export const isObject = (item: any) => (item && typeof item === "object" && !Array.isArray(item)) ? true : false; diff --git a/src/electron/frontend/core/components/pages/uploads/utils.ts b/src/electron/frontend/utils/upload.ts similarity index 80% rename from src/electron/frontend/core/components/pages/uploads/utils.ts rename to src/electron/frontend/utils/upload.ts index f82962ee74..9ead9720c4 100644 --- a/src/electron/frontend/core/components/pages/uploads/utils.ts +++ b/src/electron/frontend/utils/upload.ts @@ -1,30 +1,38 @@ import { get } from "dandi"; -import dandiUploadSchema, { regenerateDandisets } from "../../../../../../schemas/dandi-upload.schema"; +import dandiUploadSchema, { regenerateDandisets } from "../../../schemas/dandi-upload.schema"; -import { validateDANDIApiKey } from "../../../validation/dandi"; -import { Modal } from "../../Modal"; -import { header } from "../../forms/utils"; +import { validateDANDIApiKey } from "../core/validation/dandi"; +import { Modal } from "../core/components/Modal"; +import { header } from "./text"; -import { JSONSchemaInput } from "../../JSONSchemaInput"; +import { JSONSchemaInput } from "../core/components/JSONSchemaInput"; -import { Button } from "../../Button.js"; -import { global } from "../../../progress/index.js"; -import { merge } from "../utils"; +import { Page } from '../core/components/pages/Page.js'; +import { Button } from "../core/components/Button.js"; +import { global } from "../core/progress/index.js"; +import { merge } from "./data"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import { NotyfNotification } from "notyf"; + +import dandiGlobalSchema from "../../../schemas/json/dandi/global.json"; +import { isNumericString } from "./typecheck"; export const isStaging = (id: string) => parseInt(id) >= 100000; +type NotificationType = { + type: string; + message: string; +} -function isNumeric(str: string) { - if (typeof str != "string") return false // we only process strings! - return !isNaN(str) && // use type coercion to parse the _entirety_ of the string (`parseFloat` alone does not do this)... - !isNaN(parseFloat(str)) // ...and ensure strings of whitespace fail - } +type Notifications = NotificationType[]; - export async function validate (name: string, parent: any) { +export async function validate( + this: Page, + name: string, + parent: any +): Promise { const value = parent[name] @@ -36,7 +44,7 @@ function isNumeric(str: string) { } if (name === 'dandiset' && value) { - if (isNumeric(value)){ + if (isNumericString(value)) { if (value.length !== 6) return [{ type: 'error', @@ -50,7 +58,7 @@ function isNumeric(str: string) { const dandiset = await get(value, { type, token - }) + }) if (dandiset.detail) { if (dandiset.detail.includes('Not found')) return [{ @@ -80,25 +88,24 @@ function isNumeric(str: string) { }] } } + + return true } -export const willCreate = (value: string) => !isNumeric(value) +export const willCreate = (value: string) => !isNumericString(value) // Regular expression to validate an NIH award number. // Based on https://era.nih.gov/files/Deciphering_NIH_Application.pdf // and https://era.nih.gov/erahelp/commons/Commons/understandGrantNums.htm const NIH_AWARD_REGEX = /^\d \w+ \w{2} \d{6}-\d{2}([A|S|X|P]\d)?$/; -export function awardNumberValidator(awardNumber: string): boolean { - return NIH_AWARD_REGEX.test(awardNumber); -} +export const awardNumberValidator = (awardNumber: string) => NIH_AWARD_REGEX.test(awardNumber); export const AWARD_VALIDATION_FAIL_MESSAGE = 'Award number must be properly space-delimited.\n\nExample (exclude quotes):\n"1 R01 CA 123456-01A1"'; - // this: export async function getAPIKey( - // this: Page, + this: Page, staging = false ) { @@ -108,9 +115,10 @@ export async function getAPIKey( const errors = await validateDANDIApiKey(api_key, staging); - const isInvalid = !errors || errors.length; + const isInvalid = Array.isArray(errors) ? errors.length : !errors; if (isInvalid) { + const modal = new Modal({ header: `${api_key ? "Update" : "Provide"} your ${header(whichAPIKey)}`, open: true, @@ -130,9 +138,9 @@ export async function getAPIKey( modal.append(container); - let notification; + let notification: NotyfNotification; - const notify = (message, type) => { + const notify = (message: string, type: string) => { if (notification) this.dismiss(notification); return (notification = this.notify(message, type)); }; diff --git a/src/electron/frontend/utils/url.js b/src/electron/frontend/utils/url.ts similarity index 50% rename from src/electron/frontend/utils/url.js rename to src/electron/frontend/utils/url.ts index 15423f61ca..912ead1c9a 100644 --- a/src/electron/frontend/utils/url.js +++ b/src/electron/frontend/utils/url.ts @@ -7,7 +7,14 @@ export function updateURLParams(paramsToUpdate) { } // Update browser history state - const value = `${location.pathname}?${params}`; - if (history.state) Object.assign(history.state, paramsToUpdate); + const paramString = params.toString(); + const value = paramString ? `${location.pathname}?${paramString}` : location.pathname; + if (history.state) { + Object.entries(paramsToUpdate).forEach(([key, value]) => { + if (value == undefined) delete history.state[key]; + else history.state[key] = value; + }); + } + window.history.pushState(history.state, null, value); } diff --git a/src/schemas/base-metadata.schema.ts b/src/schemas/base-metadata.schema.ts index 1dfa0de588..b337c69e97 100644 --- a/src/schemas/base-metadata.schema.ts +++ b/src/schemas/base-metadata.schema.ts @@ -1,12 +1,14 @@ -import { serverGlobals, resolve } from '../electron/frontend/core/server/globals' +import { serverGlobals } from '../electron/frontend/core/server/globals' +import { resolve } from '../electron/frontend/utils/promises' -import { header, replaceRefsWithValue } from '../electron/frontend/core/components/forms/utils' +import { header } from '../electron/frontend/utils/text' +import { resolveAsJSONSchema } from '../electron/frontend/utils/data' import baseMetadataSchema from './json/base_metadata_schema.json' assert { type: "json" } -import { merge } from '../electron/frontend/core/components/pages/utils' -import { drillSchemaProperties } from '../electron/frontend/core/components/pages/guided-mode/data/utils' -import { getISODateInTimezone } from './timezone.schema' +import { merge } from '../electron/frontend/utils/data' +import { drillSchemaProperties } from '../electron/frontend/utils/data' +import { getISODateInTimezone } from '../electron/frontend/utils/time' const UV_MATH_FORMAT = `µV`; //`µV` @@ -73,7 +75,7 @@ function updateEcephysTable(propName, schema, schemaToMerge) { export const preprocessMetadataSchema = (schema: any = baseMetadataSchema, global = false) => { - const copy = replaceRefsWithValue(structuredClone(schema)) + const copy = resolveAsJSONSchema(structuredClone(schema)) // NEUROCONV PATCH: Correct for incorrect array schema drillSchemaProperties( diff --git a/src/schemas/dandi-create.schema.ts b/src/schemas/dandi-create.schema.ts deleted file mode 100644 index 7b08cd927e..0000000000 --- a/src/schemas/dandi-create.schema.ts +++ /dev/null @@ -1,4 +0,0 @@ -import create from './json/dandi/create.json' assert { type: "json" } -const schema = structuredClone(create) - -export default schema diff --git a/src/schemas/dandi-upload.schema.ts b/src/schemas/dandi-upload.schema.ts index 88cc8257e0..1554e224f1 100644 --- a/src/schemas/dandi-upload.schema.ts +++ b/src/schemas/dandi-upload.schema.ts @@ -2,7 +2,7 @@ import { Dandiset, getMine } from 'dandi' import { global } from '../electron/frontend/core/progress' import upload from './json/dandi/upload.json' assert { type: "json" } -import { isStaging } from '../electron/frontend/core/components/pages/uploads/utils' +import { isStaging } from '../electron/frontend/utils/upload' import { baseUrl, onServerOpen } from '../electron/frontend/core/server/globals' import { isStorybook } from '../electron/frontend/core/globals' diff --git a/src/schemas/project-metadata.schema.ts b/src/schemas/project-metadata.schema.ts deleted file mode 100644 index e31806c08a..0000000000 --- a/src/schemas/project-metadata.schema.ts +++ /dev/null @@ -1,3 +0,0 @@ -import projectMetadataSchema from './json/project-metadata.schema.json' assert { type: "json" } - -export default projectMetadataSchema diff --git a/src/schemas/source-data.schema.ts b/src/schemas/source-data.schema.ts index 72c7ba9c43..95f2afca02 100644 --- a/src/schemas/source-data.schema.ts +++ b/src/schemas/source-data.schema.ts @@ -9,7 +9,7 @@ export default function preprocessSourceDataSchema (schema) { }, {}) // Abstract across different interfaces - Object.entries(schema.properties ?? {}).forEach(([key, schema]: [string, any]) => { + Object.entries(schema.properties ?? {}).forEach(([ key, schema ]: [string, any]) => { const info = interfaces[key] ?? {} diff --git a/src/schemas/timezone.schema.ts b/src/schemas/timezone.schema.ts index 8455387fe5..d14dcc4f7b 100644 --- a/src/schemas/timezone.schema.ts +++ b/src/schemas/timezone.schema.ts @@ -1,6 +1,6 @@ import { baseUrl, onServerOpen } from "../electron/frontend/core/server/globals"; import { isStorybook } from '../electron/frontend/core/globals' -import { header } from "../electron/frontend/core/components/forms/utils"; +import { header } from "../electron/frontend/utils/text"; const setReady: any = {} @@ -31,9 +31,6 @@ onServerOpen(async () => { }); }); - - - export const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; // export const getTimeZoneName = (timezone, timeZoneName = 'long') => new Date().toLocaleDateString(undefined, {day:'2-digit', timeZone: timezone, timeZoneName }).substring(4) @@ -44,42 +41,6 @@ export const timezoneProperties = [ [ "Subject", "date_of_birth" ] ] -export const getTimezoneOffset = ( - date = new Date(), - timezone = localTimeZone -) => { - - if (typeof date === 'string') date = new Date(date) - - const utcDate = new Date(date.toLocaleString('en-US', { timeZone: 'UTC' })); - const tzDate = new Date(date.toLocaleString('en-US', { timeZone: timezone })); - return utcDate.getTime() - tzDate.getTime(); -} - -export const formatTimezoneOffset = ( - milliseconds: number -) => { - let offsetInMinutes = -((milliseconds / 1000) / 60); // getTimezoneOffset returns the difference in minutes from UTC - const sign = offsetInMinutes >= 0 ? "+" : "-"; - offsetInMinutes = Math.abs(offsetInMinutes); - const hours = String(Math.floor(offsetInMinutes / 60)).padStart(2, "0"); - const minutes = String(offsetInMinutes % 60).padStart(2, "0"); - return `${sign}${hours}:${minutes}`; -} - -export function getISODateInTimezone( - date = new Date(), - timezone = localTimeZone -) { - - if (typeof date === 'string') date = new Date(date) - - const offset = getTimezoneOffset(date, timezone) - const adjustedDate = new Date(date.getTime() - offset); - return adjustedDate.toISOString(); -} - - const timezoneSchema = { type: "string", description: "Provide a base timezone for all date and time operations in the GUIDE.", diff --git a/stories/components/InspectorList.stories.js b/stories/components/InspectorList.stories.js index a26ab02869..c59c2d1c07 100644 --- a/stories/components/InspectorList.stories.js +++ b/stories/components/InspectorList.stories.js @@ -1,4 +1,4 @@ -import { InspectorList } from "../../src/electron/frontend/core/components/preview/inspector/InspectorList"; +import { InspectorList } from "../../src/electron/frontend/core/components/InspectorList"; import testInspectorList from "../inputs/inspector_output.json"; export default { diff --git a/stories/components/Multiselect.stories.js b/stories/components/Multiselect.stories.js deleted file mode 100644 index 211e2d8a65..0000000000 --- a/stories/components/Multiselect.stories.js +++ /dev/null @@ -1,34 +0,0 @@ -import { MultiSelectForm } from "../../src/electron/frontend/core/components/multiselect/MultiSelectForm.js"; - -export default { - title: "Components/Multiselect Form", - parameters: { - chromatic: { disableSnapshot: false }, - }, -}; - -const Template = (args) => new MultiSelectForm(args); - -export const Default = Template.bind({}); -Default.args = { - header: "Test Header", - options: { - option1: { - name: "Option 1", - modality: "Modality 1", - technique: "Technique 1", - }, - - option2: { - name: "Option 2", - modality: "Modality 1", - technique: "Technique 1", - }, - - otheroption1: { - name: "Other Option 1", - modality: "Modality 2", - technique: "Technique 1", - }, - }, -}; diff --git a/stories/components/StatusBar.stories.js b/stories/components/StatusBar.stories.js index a3c9bd5552..a272036d7c 100644 --- a/stories/components/StatusBar.stories.js +++ b/stories/components/StatusBar.stories.js @@ -1,4 +1,4 @@ -import { StatusBar } from "../../src/electron/frontend/core/components/status/StatusBar"; +import { StatusBar } from "../../src/electron/frontend/core/components/StatusBar"; import { unsafeSVG } from "lit/directives/unsafe-svg.js"; import pythonSVG from "../../src/electron/frontend/assets/icons/python.svg?raw"; import webAssetSVG from "../../src/electron/frontend/assets/icons/web_asset.svg?raw"; diff --git a/tests/e2e/pipelines.test.ts b/tests/e2e/pipelines.test.ts index 290ebd10a8..053cfcc9cd 100644 --- a/tests/e2e/pipelines.test.ts +++ b/tests/e2e/pipelines.test.ts @@ -10,7 +10,7 @@ import examplePipelines from "../../src/example_pipelines.yml"; import paths from "../../src/paths.config.json" assert { type: "json" }; import { evaluate, initTests, takeScreenshot } from './utils' -import { header } from '../../src/electron/frontend/core/components/forms/utils' +import { header } from '../../src/electron/frontend/utils/text' import { sleep } from '../puppeteer'; diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 69b87a9b28..66b8c91d98 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -1,14 +1,13 @@ import { describe, expect, test } from 'vitest' -import { createResults } from '../src/electron/frontend/core/components/pages/guided-mode/data/utils' -import { mapSessions } from '../src/electron/frontend/core/components/pages/utils' +import { createResultsForSession } from '../src/electron/frontend/utils/data' +import { mapSessions } from '../src/electron/frontend/utils/data' import baseMetadataSchema from '../src/schemas/base-metadata.schema' import { createMockGlobalState } from './utils' import { Validator } from 'jsonschema' -import { textToArray } from '../src/electron/frontend/core/components/forms/utils' -import { updateResultsFromSubjects } from '../src/electron/frontend/core/components/pages/guided-mode/setup/utils' +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"; @@ -30,19 +29,12 @@ describe('metadata is specified correctly', () => { // Allow mouse (full list populated from server) baseMetadataSchema.properties.Subject.properties.species.enum = ['Mus musculus'] - const result = mapSessions(info => createResults(info, globalState), globalState.results) + const result = mapSessions(info => createResultsForSession(info, globalState), globalState.results) const res = validator.validate(result[0], baseMetadataSchema) // Check first session with JSON Schema expect(res.errors).toEqual([]) }) }) -test('empty rows are not kept for strings converted to arrays', () => { - expect(textToArray(' v1\n v2 ')).toEqual(['v1', 'v2']) - expect(textToArray(' v1\n\n v2 ')).toEqual(['v1', 'v2']) - expect(textToArray(' v1\n \n v2 ')).toEqual(['v1', 'v2']) - expect(textToArray(' v1\n v3\n v2 ')).toEqual(['v1', 'v3', 'v2']) -}) - test('removing all existing sessions will maintain the related subject entry on the results object', () => { const results = { subject: { original: {} } } diff --git a/tests/schemas.test.ts b/tests/schemas.test.ts new file mode 100644 index 0000000000..4fe0f6c433 --- /dev/null +++ b/tests/schemas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { preprocessMetadataSchema } from "../src/schemas/base-metadata.schema"; +import processSubjectSchema from "../src/schemas/subject.schema"; + +// import '../src/schemas/source-data.schema' + +describe("Check changes to the Subject schema", () => { + + const baseSchema = preprocessMetadataSchema() + + const schema = processSubjectSchema(baseSchema) + + test("Check sessions property is added to the Subject schema", () => { + expect(schema.properties.sessions).toBeDefined() + expect(schema.required.includes("sessions")).toBe(true) + }) + + test("Check that the Subject schema is sorted correctly", () => { + + const desiredOrder = [ + "sessions", + "subject_id", + "sex", + "species", + "age", + "age__reference", + "date_of_birth", + "genotype", + "strain", + "description", + "weight", + ] + + const sortedKeys = Object.keys(schema.properties) + + expect(sortedKeys).toEqual(desiredOrder) + + }) +}) diff --git a/tests/utils.test.ts b/tests/utils.test.ts new file mode 100644 index 0000000000..a05d2fd6f2 --- /dev/null +++ b/tests/utils.test.ts @@ -0,0 +1,523 @@ +import { describe, expect, test } from "vitest"; + +import * as time from "../src/electron/frontend/utils/time"; +import * as url from "../src/electron/frontend/utils/url"; +import * as bytes from "../src/electron/frontend/utils/bytes"; +import * as text from "../src/electron/frontend/utils/text"; +import * as typecheck from "../src/electron/frontend/utils/typecheck"; +import * as random from "../src/electron/frontend/utils/random"; +import * as data from "../src/electron/frontend/utils/data"; + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; + +describe('URL Utilities', () => { + let originalLocation: Location; + let originalHistory: History; + + beforeEach(() => { + // Save the original location and history objects + originalLocation = global.location; + originalHistory = global.history; + + // Mock location object + global.location = { + search: '', + pathname: '/test', + }; + + // Mock history object + global.history = { + state: {}, + pushState: vi.fn(), + }; + + }); + + afterEach(() => { + // Restore the original location and history objects + global.location = originalLocation; + global.history = originalHistory; + }); + + it('should add new parameters to the URL', () => { + url.updateURLParams({ foo: 'bar' }); + expect(global.history.pushState).toHaveBeenCalledWith({ foo: "bar" }, null, '/test?foo=bar'); + }); + + it('should update existing parameters in the URL', () => { + global.location.search = '?foo=old'; + url.updateURLParams({ foo: 'new' }); + expect(global.history.pushState).toHaveBeenCalledWith({ foo: "new" }, null, '/test?foo=new'); + }); + + it('should delete parameters from the URL when value is undefined', () => { + global.location.search = '?foo=bar'; + url.updateURLParams({ foo: undefined }); + expect(global.history.pushState).toHaveBeenCalledWith({}, null, '/test'); + }); + + it('should handle multiple parameters correctly', () => { + global.location.search = '?foo=bar&baz=qux'; + url.updateURLParams({ foo: 'new', baz: undefined, quux: 'corge' }); + expect(global.history.pushState).toHaveBeenCalledWith({ foo: "new", quux: "corge" }, null, '/test?foo=new&quux=corge'); + }); + + it('should merge new parameters with existing state', () => { + global.history.state = { existing: 'value' }; + url.updateURLParams({ foo: 'bar' }); + expect(global.history.state).toEqual({ existing: 'value', foo: 'bar' }); + }); + + it('should not change the state if history.state is null', () => { + global.history.state = null; + url.updateURLParams({ foo: 'bar' }); + expect(global.history.pushState).toHaveBeenCalledWith(null, null, '/test?foo=bar'); + }); +}); + + +describe('Timezone Utilities', () => { + const originalDateNow = Date.now; + + beforeEach(() => { + Date.now = originalDateNow; // Reset Date.now() mock if it was used + }); + + describe('getTimezoneOffset', () => { + it('should return the correct timezone offset for the local timezone', () => { + const offset = time.getTimezoneOffset(new Date('2023-06-12T12:00:00Z')); + expect(offset).toBeTypeOf('number'); + }); + + it('should return the correct timezone offset for a specific timezone', () => { + const offset = time.getTimezoneOffset(new Date('2023-06-12T12:00:00Z'), 'America/New_York'); + expect(offset).toBe(14400000); // 4 hours offset in milliseconds + }); + + it('should handle string date inputs correctly', () => { + const offset = time.getTimezoneOffset('2023-06-12T12:00:00Z', 'America/New_York'); + expect(offset).toBe(14400000); // 4 hours offset in milliseconds + }); + }); + + describe('formatTimezoneOffset', () => { + it('should format positive offset correctly', () => { + const formattedOffset = time.formatTimezoneOffset(-14400000); + expect(formattedOffset).toBe('+04:00'); + }); + + it('should format negative offset correctly', () => { + const formattedOffset = time.formatTimezoneOffset(14400000); + expect(formattedOffset).toBe('-04:00'); + }); + + it('should format zero offset correctly', () => { + const formattedOffset = time.formatTimezoneOffset(0); + expect(formattedOffset).toBe('+00:00'); + }); + }); + + describe('getISODateInTimezone', () => { + + it('should return the correct ISO date for a specific timezone', () => { + const isoDate = time.getISODateInTimezone(new Date('2023-06-12T12:00:00Z'), 'America/New_York'); + expect(isoDate).toBe('2023-06-12T08:00:00.000Z'); // Adjusted for 4 hours offset + }); + + it('should handle string date inputs correctly', () => { + const isoDate = time.getISODateInTimezone('2023-06-12T12:00:00Z', 'America/New_York'); + expect(isoDate).toBe('2023-06-12T08:00:00.000Z'); // Adjusted for 4 hours offset + }); + }); + +}); + +describe('Byte Utilities', () => { + test('should correctly format bytes', () => { + const number = 500; + expect(bytes.humanReadableBytes(number)).toBe('500.00 B'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('500.00 B'); + }); + + test('should correctly format kilobytes', () => { + const number = 1500; + expect(bytes.humanReadableBytes(number)).toBe('1.50 KB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 KB'); + }); + + test('should correctly format megabytes', () => { + const number = 1500000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 MB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 MB'); + }); + + test('should correctly format gigabytes', () => { + const number = 1500000000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 GB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 GB'); + }); + + test('should correctly format terabytes', () => { + const number = 1500000000000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 TB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 TB'); + }); + + test('should correctly format petabytes', () => { + const number = 1500000000000000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 PB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 PB'); + }); + + test('should correctly format exabytes', () => { + const number = 1500000000000000000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 EB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 EB'); + }); + + test('should correctly format zettabytes', () => { + const number = 1500000000000000000000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 ZB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 ZB'); + }); + + test('should correctly format yottabytes', () => { + const number = 1500000000000000000000000; + expect(bytes.humanReadableBytes(number)).toBe('1.50 YB'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('1.50 YB'); + }); + + test('should handle edge cases with 0 bytes', () => { + const number = 0; + expect(bytes.humanReadableBytes(number)).toBe('0.00 B'); + expect(bytes.humanReadableBytes(`${number}`)).toBe('0.00 B'); + }); + + test('should handle very large numbers appropriately', () => { + const number = 1e27 + const expected = '1000.00 YB'; + expect(bytes.humanReadableBytes(number)).toBe(expected); + expect(bytes.humanReadableBytes(`${number}`)).toBe(expected); + }); +}); + + +describe('Text Utilities', () => { + + describe('header', () => { + it('should capitalize and format header strings correctly', () => { + expect(text.header('hello_world')).toBe('Hello World'); + expect(text.header('HELLO_WORLD')).toBe('HELLO WORLD'); // Do not adjust user-defined capitalization + expect(text.header('hello world')).toBe('Hello World'); + expect(text.header('api and id')).toBe('API and ID'); + expect(text.header('nwb_or_and')).toBe('NWB or and'); + expect(text.header('nwb or and api')).toBe('NWB or and API'); + }); + + it('should handle strings with multiple delimiters correctly', () => { + expect(text.header('hello_world_and_everyone')).toBe('Hello World and Everyone'); + expect(text.header('api_or_nwb_id')).toBe('API or NWB ID'); + expect(text.header('nwb or_and api')).toBe('NWB or and API'); + }); + + it('should handle empty strings', () => { + expect(text.header('')).toBe(''); + }); + + it('should handle strings with only delimiters', () => { + expect(text.header('_ _')).toBe(''); + expect(text.header('_or_')).toBe('or'); + }); + + it('consider words linked with a special character as one word', () => { + expect(text.header('hello_world-and-all')).toBe('Hello World-and-all'); + }); + + }); + +}) + +describe('Type Check Utilities', () => { + describe('isNumeric', () => { + it('should return false for non-string inputs', () => { + expect(typecheck.isNumericString(123)).toBe(false); + expect(typecheck.isNumericString(true)).toBe(false); + expect(typecheck.isNumericString(null)).toBe(false); + expect(typecheck.isNumericString(undefined)).toBe(false); + expect(typecheck.isNumericString({})).toBe(false); + expect(typecheck.isNumericString([])).toBe(false); + }); + + it('should return true for numeric strings', () => { + expect(typecheck.isNumericString('123')).toBe(true); + expect(typecheck.isNumericString('123.45')).toBe(true); + expect(typecheck.isNumericString('-123')).toBe(true); + expect(typecheck.isNumericString('-123.45')).toBe(true); + expect(typecheck.isNumericString('0')).toBe(true); + }); + + it('should return false for non-numeric strings', () => { + expect(typecheck.isNumericString('abc')).toBe(false); + expect(typecheck.isNumericString('123abc')).toBe(false); + expect(typecheck.isNumericString('abc123')).toBe(false); + expect(typecheck.isNumericString('')).toBe(false); + expect(typecheck.isNumericString(' ')).toBe(false); + }); + + it('should handle strings with spaces', () => { + expect(typecheck.isNumericString(' 123 ')).toBe(true); + expect(typecheck.isNumericString(' 123.45 ')).toBe(true); + expect(typecheck.isNumericString(' -123 ')).toBe(true); + }); + + it('should handle special numeric values', () => { + expect(typecheck.isNumericString('Infinity')).toBe(true); + expect(typecheck.isNumericString('-Infinity')).toBe(true); + expect(typecheck.isNumericString('NaN')).toBe(false); + }); + }); + describe('isObject', () => { + + it('should return true for objects', () => { + expect(typecheck.isObject({})).toBe(true); + expect(typecheck.isObject({ foo: 'bar' })).toBe(true); + expect(typecheck.isObject(new Date())).toBe(true); + expect(typecheck.isObject(Object.create(null))).toBe(true); + }); + + it('should return false for non-objects', () => { + expect(typecheck.isObject(123)).toBe(false); + expect(typecheck.isObject('string')).toBe(false); + expect(typecheck.isObject(true)).toBe(false); + expect(typecheck.isObject(null)).toBe(false); + expect(typecheck.isObject(undefined)).toBe(false); + }); + + it('should return false for arrays', () => { + expect(typecheck.isObject([])).toBe(false); + expect(typecheck.isObject([1, 2, 3])).toBe(false); + }); + + it('should handle special cases', () => { + expect(typecheck.isObject(function () { })).toBe(false); + expect(typecheck.isObject(Symbol('symbol'))).toBe(false); + }); + }) + + +}) + + +describe('Randomization Utilities', () => { + + describe('randomIndex', () => { + it('should return a random index within the given count', () => { + const count = 10; + const index = random.getRandomIndex(count); + expect(index).toBeGreaterThanOrEqual(0); + expect(index).toBeLessThan(count); + }); + + it('should return 0 for count of 1', () => { + const count = 1; + const index = random.getRandomIndex(count); + expect(index).toBe(0); + }); + }); + + describe('getRandomSample', () => { + it('should return an array with the given count of random elements', () => { + const array = [1, 2, 3, 4, 5]; + const count = 3; + const result = random.getRandomSample(array, count); + expect(result).toHaveLength(count); + result.forEach(element => { + expect(array).toContain(element); + }); + }); + + it('should throw an error if count is greater than array length', () => { + const array = [1, 2, 3]; + const count = 5; + expect(() => random.getRandomSample(array, count)).toThrow('Array size cannot be smaller than expected random numbers count.'); + }); + + it('should not have duplicate elements in the result', () => { + const array = [1, 2, 3, 4, 5]; + const count = 3; + const result = random.getRandomSample(array, count); + const uniqueResult = new Set(result); + expect(uniqueResult.size).toBe(result.length); + }); + + it('should return an empty array if count is 0', () => { + const array = [1, 2, 3]; + const count = 0; + const result = random.getRandomSample(array, count); + expect(result).toEqual([]); + }); + + it('should return the full array if count equals array length', () => { + const array = [1, 2, 3]; + const count = array.length; + const result = random.getRandomSample(array, count); + expect(result.sort()).toEqual(array.sort()); + }); + }); + + describe('getRandomString', () => { + it('should return a string', () => { + const result = random.getRandomString(); + expect(typeof result).toBe('string'); + }); + + 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 + }); + + it('should return a different string each time it is called', () => { + const result1 = random.getRandomString(); + const result2 = random.getRandomString(); + expect(result1).not.toBe(result2); + }); + }); + +}) + + +describe('Data Manipulation Utilities', () => { + + describe('populateWithProjectMetadata', () => { + it('should merge project metadata into the provided info object', () => { + const info = { key1: { subKey: 'value1' } }; + const globalState = { project: { key1: { subKey2: 'value2' }, key2: { subKey3: 'value3' } } }; + const result = data.populateWithProjectMetadata(info, globalState); + expect(result).toEqual({ + key1: { subKey: 'value1', subKey2: 'value2' }, + key2: { subKey3: 'value3' } + }); + }); + }); + + describe('getInfoFromId', () => { + it('should extract subject and session from the key', () => { + const key = 'sub-1234/ses-5678'; + const result = data.getInfoFromId(key); + expect(result).toEqual({ subject: '1234', session: '5678' }); + }); + }); + + describe('resolveGlobalOverrides', () => { + it('should merge subject metadata and project-wide metadata', () => { + const subject = 'testSubject'; + const globalState = { subjects: { testSubject: { testKey: 'testValue' } }, project: { Subject: { projectKey: 'projectValue' } } }; + const result = data.resolveGlobalOverrides(subject, globalState); + expect(result.Subject).toEqual({ testKey: 'testValue', projectKey: 'projectValue' }); + }); + }); + + describe('drillSchemaProperties', () => { + it('should call the callback for each property in the schema', () => { + const schema = { properties: { key1: { type: 'string' }, key2: { type: 'number' } } }; + const callback = vi.fn(); + data.drillSchemaProperties(schema, callback); + expect(callback).toHaveBeenCalledTimes(2); + }); + }); + + describe('resolveProperties', () => { + it('should resolve properties and apply default values', () => { + const properties = { key1: { type: 'string', default: 'defaultValue' } }; + const target = {}; + data.resolveProperties(properties, target); + expect(target).toEqual({ key1: 'defaultValue' }); + }); + }); + + describe('resolveMetadata', () => { + it('should resolve metadata for the subject and session', () => { + const subject = 'testSubject'; + const session = 'testSession'; + const globalState = { + results: { testSubject: { testSession: { metadata: { testKey: 'testValue' } } } }, + schema: { metadata: { testSubject: { testSession: { properties: { testKey: { type: 'string' } } } } } }, + project: {} + }; + const result = data.resolveMetadata(subject, session, globalState); + expect(result).toEqual({ testKey: 'testValue' }); + }); + }); + + describe('createResultsForSession', () => { + it('should create results with project metadata and subject globals', () => { + const input = { subject: 'testSubject', info: { metadata: { NWBFile: { key1: 'value1' } } } }; + const globalState = { project: { NWBFile: { key1: 'projectValue1', global: true } }, subjects: { testSubject: { key2: 'value2' } } }; + const result = data.createResultsForSession(input, globalState); + expect(result).toEqual({ NWBFile: { key1: 'value1', global:true }, Subject: { key2: 'value2' } }); // Input value has priority over project metadata + }); + }); + + describe('updateResultsFromSubjects', () => { + it('should update results based on the subjects', () => { + const results = { subject1: { session1: { metadata: { testKey: 'testValue' } } } }; + const subjects = { subject1: { sessions: ['session1'] }, subject2: { sessions: ['session2'] } }; + const result = data.updateResultsFromSubjects(results, subjects); + expect(result).toEqual({ + subject1: { session1: { metadata: { testKey: 'testValue' } } }, + subject2: { session2: { source_data: {}, metadata: { NWBFile: { session_id: 'session2' }, Subject: { subject_id: 'subject2' } } } } + }); + }); + }); + + describe('setUndefinedIfNotDeclared', () => { + it('should set undefined for properties not declared', () => { + const schemaProps = { properties: { key1: { type: 'string' } } }; + const resolved = {}; + data.setUndefinedIfNotDeclared(schemaProps, resolved); + expect(resolved).toEqual({ key1: undefined }); + }); + }); + + describe('sanitize', () => { + it('should remove private properties from the object', () => { + const item = { __private: 'value', public: 'value' }; + const result = data.sanitize(item); + expect(result).toEqual({ public: 'value' }); + }); + }); + + describe('merge', () => { + it('should merge two objects deeply', () => { + const toMerge = { key1: 'value1', key2: { subKey: 'value2' } }; + const target = { key2: { subKey: 'oldValue', subKey2: 'value3' } }; + const result = data.merge(toMerge, target); + expect(result).toEqual({ key1: 'value1', key2: { subKey: 'value2', subKey2: 'value3' } }); + }); + }); + + describe('mapSessions', () => { + it('should map sessions using the provided callback', () => { + const toIterate = { subject1: { session1: 'info1', session2: 'info2' } }; + const callback = ({ subject, session, info }) => `${subject}/${session}: ${info}`; + const result = data.mapSessions(callback, toIterate); + expect(result).toEqual(['subject1/session1: info1', 'subject1/session2: info2']); + }); + }); + + describe('resolveAsJSONSchema', () => { + it('should replace $ref with the referenced value', () => { + const schema = { properties: { key1: { $ref: '#/definitions/ref1' } }, definitions: { ref1: { type: 'string' } } }; + const result = data.resolveAsJSONSchema(schema); + expect(result).toEqual({ properties: { key1: { type: 'string' } }, definitions: { ref1: { type: 'string' } } }); + }); + + it('it should resolve allOf', () => { + const schema = { properties: { key1: { allOf: [{ type: 'string' }, { minLength: 5 }] } } }; + const result = data.resolveAsJSONSchema(schema,); + expect(result).toEqual({ properties: { key1: { type: 'string', minLength: 5 } } }); + }) + }); + +}) diff --git a/vite.config.js b/vite.config.js index 49241f41ba..e79e9e8739 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,5 +8,45 @@ export default defineConfig({ environment: "jsdom", setupFiles: ["dotenv/config"], testTimeout: 4 * 60 * 1000, + coverage: { + include: ["src"], + exclude: [ + "**/assets", + + // No Electron code should be tested + "**/electron/main", + "**/electron/preload", + + // No test for the rendered pages, as they're composed of components + "**/components/pages", + + // Electron Only (for the most part) + "src/schemas/dandi-upload.schema.ts", + "src/schemas/interfaces.info.ts", + "src/schemas/timezone.schema.ts", + "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", + "src/electron/frontend/utils/progress.ts", + + // Pure Native Rendering Interaction + "src/electron/frontend/utils/table.ts", + + // Unclear how to test + "src/electron/frontend/utils/popups.ts", + "src/electron/frontend/utils/download.ts", + "src/electron/frontend/utils/upload.ts", + ], + }, }, });