From c3f85627e9f54c5e9667b069aa6cb6ff3e24c9b1 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Mon, 20 May 2024 13:18:21 -0700 Subject: [PATCH 01/18] Add auto-update workflow --- src/main/main.ts | 64 ++++++++++++-- src/renderer/assets/css/custom.css | 53 +++++++++++ src/renderer/assets/css/nav.css | 20 ++++- src/renderer/src/electron/index.js | 37 ++++++++ src/renderer/src/pages.js | 6 +- src/renderer/src/stories/Dashboard.js | 2 + src/renderer/src/stories/ProgressBar.ts | 8 +- src/renderer/src/stories/assets/dandi.svg | 2 +- src/renderer/src/stories/assets/download.svg | 1 + .../src/stories/assets/exploration.svg | 2 +- src/renderer/src/stories/assets/info.svg | 1 + src/renderer/src/stories/assets/inspect.svg | 2 +- .../src/stories/assets/neurosift-logo.svg | 2 +- src/renderer/src/stories/assets/preview.svg | 2 +- src/renderer/src/stories/assets/settings.svg | 2 +- .../stories/pages/settings/SettingsPage.js | 87 ++++++++++++++++++- src/renderer/src/stories/sidebar.js | 12 ++- 17 files changed, 283 insertions(+), 20 deletions(-) create mode 100644 src/renderer/assets/css/custom.css create mode 100644 src/renderer/src/stories/assets/download.svg create mode 100644 src/renderer/src/stories/assets/info.svg diff --git a/src/main/main.ts b/src/main/main.ts index a434dcf135..1d7583f8ff 100755 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -21,16 +21,24 @@ import icon from '../renderer/assets/img/logo-guide-draft.png?asset' import splashHTML from './splash-screen.html?asset' import preloadUrl from './../preload/preload.js?asset' +import devUpdateConfig from './dev-app-update.yml?asset' + + +import { autoUpdater } from 'electron-updater'; + + const runByTestSuite = !!process.env.VITEST +// Configure AutoUpdater +autoUpdater.autoDownload = false; // Disable auto download of updates +autoUpdater.channel = "latest"; + // Enable remote debugging port for Vitest if (runByTestSuite) { app.commandLine.appendSwitch('remote-debugging-port', `${8315}`) // Mirrors the global electronDebugPort variable app.commandLine.appendSwitch('remote-allow-origins', '*') // Allow all remote origins } -// autoUpdater.channel = "latest"; - /************************************************************* * Python Process *************************************************************/ @@ -215,8 +223,6 @@ const killAllPreviousProcesses = async () => { } else console.error('Cannot kill previous processes because fetch is not defined in this version of Node.js') }; -let updatechecked = false; - let hasBeenOpened = false; function initialize() { @@ -352,8 +358,8 @@ function initialize() { win.show(); createWindow(); - // autoUpdater.checkForUpdatesAndNotify(); - updatechecked = true; + // Check for updates + autoUpdater.checkForUpdates(); // Clear ready queue readyQueue.forEach(f => onWindowReady(f)) @@ -361,6 +367,7 @@ function initialize() { }, hasBeenOpened ? 100 : 1000); }); + } @@ -515,3 +522,48 @@ ipcMain.on("get-server-file-path", (event) => { ipcMain.on("python.status", (event) => { if (globals.python.sent) ((globals.python.status) ? pythonIsOpen : pythonIsClosed)(true); // Force send }); + +// Add auto-updater events + +ipcMain.on('download-update', () => { + autoUpdater.downloadUpdate() +}) + +autoUpdater.on('update-available', (info) => { + onWindowReady((win) => send.call(win, 'update-available', info)) +}); + +autoUpdater.on('update-not-available', () => { + onWindowReady((win) => send.call(win, 'update-available', false)) +}); + +autoUpdater.on('error', (err) => { + onWindowReady((win) => send.call(win, 'update-error', err)) +}); + +autoUpdater.on('download-progress', (progressObj) => { + onWindowReady((win) => send.call(win, 'update-progress', { + n: progressObj.transferred, + total: progressObj.total, + rate: progressObj.bytesPerSecond, + })) +}); + +autoUpdater.on('update-downloaded', (info) => { + + onWindowReady((win) => { + send.call(win, 'update-complete', info) + + // Prompt user to install update + dialog + .showMessageBox(win, { + type: 'info', + buttons: ['Restart', 'Later'], + title: 'Update available', + message: 'A new version has been downloaded. Restart now to install the update?', + }) + .then((result) => { + if (result.response === 0) autoUpdater.quitAndInstall(); + }); + }) +}); diff --git a/src/renderer/assets/css/custom.css b/src/renderer/assets/css/custom.css new file mode 100644 index 0000000000..82335470a6 --- /dev/null +++ b/src/renderer/assets/css/custom.css @@ -0,0 +1,53 @@ + +/* Update Notification Box */ +#update-available:not(:empty) { + margin-top: 15px; + background-color: #f8d2b5; + color: black; + padding: 15px 10px; + border: 1px solid #f6c39c; + border-radius: 5px; +} + + +#update-available .update-container { + display: flex; + align-items: center; + gap: 15px; +} + +#update-available .update-container::before { + content: ""; + width: 6px; + height: 6px; + margin-left: 5px; + background-color: #d17128; + border-radius: 50%; +} + +#update-available .header { + display: flex; + align-items: center; + gap: 5px; +} + +#update-available h4 { + margin: 0; +} + +#update-available span { + color: gray; + font-size: 80%; +} + +#update-available .header svg { + height: 15px; + width: auto; + cursor: pointer; +} + +#update-available .controls { + margin-left: auto; + display: flex; + gap: 10px; +} \ No newline at end of file diff --git a/src/renderer/assets/css/nav.css b/src/renderer/assets/css/nav.css index 50d67e6fb3..f449fe5a21 100755 --- a/src/renderer/assets/css/nav.css +++ b/src/renderer/assets/css/nav.css @@ -156,10 +156,13 @@ a[data-toggle="collapse"] { padding: 10px; } + #main-nav .sidebar-body a { font-size: 14px; - display: block; - line-height: 45px; + display: flex; + align-items: center; + position: relative; + padding: 15px 0px; padding-left: 20px; margin-bottom: 5px; text-align: left; @@ -170,6 +173,19 @@ a[data-toggle="collapse"] { border-left: 4px solid transparent; } +[data-update-available] [data-id="settings"] > div { + display: flex; + flex-direction: column; +} + +[data-update-available] [data-id="settings"] > div::after { + content: "Update Available"; + color: gray; + font-size: 70%; + font-weight: normal; + line-height: 1.5; +} + #main-nav .sidebar-body svg { fill: #000; } diff --git a/src/renderer/src/electron/index.js b/src/renderer/src/electron/index.js index d347798603..12c0feeef0 100644 --- a/src/renderer/src/electron/index.js +++ b/src/renderer/src/electron/index.js @@ -14,6 +14,34 @@ export let path = null; export let log = null; export let crypto = null; + +let updateAvailable = false; +const updateAvailableCallbacks = []; +export const onUpdateAvailable = (callback) => { + if (updateAvailable) callback(updateAvailable); + else updateAvailableCallbacks.push(callback); +} + +let updateProgress = null; + +const updateProgressCallbacks = []; +export const onUpdateProgress = (callback) => { + if (updateProgress) callback(updateProgress); + else updateProgressCallbacks.push(callback); +} + +export const registerUpdateProgress = (info) => { + updateProgress = info; + updateProgressCallbacks.forEach((cb) => cb(info)); +} + +const registerUpdate = (info) => { + updateAvailable = info + document.body.setAttribute('data-update-available', JSON.stringify(info)) + updateAvailableCallbacks.forEach((cb) => cb(info)) +} + + // Used in tests try { crypto = require("crypto"); @@ -39,6 +67,15 @@ if (isElectron) { electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) ); + electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)) + + electron.ipcRenderer.on(`update-available`, (_, info) => info ? registerUpdate(info) : '') + + electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)) + electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)) + + electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)) + port = electron.ipcRenderer.sendSync("get-port"); console.log("User OS:", os.type(), os.platform(), "version:", os.release()); diff --git a/src/renderer/src/pages.js b/src/renderer/src/pages.js index 4cadf89649..f8d8fded1a 100644 --- a/src/renderer/src/pages.js +++ b/src/renderer/src/pages.js @@ -45,7 +45,7 @@ const guidedIcon = ` fill="white" class="bi bi-compass-fill" viewBox="0 0 16 16" - style="margin-right: 30px; margin-bottom: -5px" + style="margin-right: 30px;" > diff --git a/src/renderer/src/stories/Dashboard.js b/src/renderer/src/stories/Dashboard.js index e56b502731..2c088f284a 100644 --- a/src/renderer/src/stories/Dashboard.js +++ b/src/renderer/src/stories/Dashboard.js @@ -5,6 +5,8 @@ import { Main, checkIfPageIsSkipped } from "./Main.js"; import { Sidebar } from "./sidebar.js"; import { NavigationSidebar } from "./NavigationSidebar.js"; +import "../../assets/css/custom.css"; // Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) + // Global styles to apply with the dashboard import "../../assets/css/variables.css"; import "../../assets/css/nativize.css"; diff --git a/src/renderer/src/stories/ProgressBar.ts b/src/renderer/src/stories/ProgressBar.ts index 06da911777..d9afd5af48 100644 --- a/src/renderer/src/stories/ProgressBar.ts +++ b/src/renderer/src/stories/ProgressBar.ts @@ -102,6 +102,12 @@ export class ProgressBar extends LitElement { const percent = this.format.total ? 100 * (this.format.n / this.format.total) : 0; const remaining = this.format.rate && this.format.total ? (this.format.total - this.format.n) / this.format.rate : 0; // Seconds + const elapsed = this.format.elapsed + + let subMessage = '' + if ('elapsed' in this.format && 'rate' in this.format) subMessage = `${elapsed?.toFixed(1)}s elapsed, ${remaining.toFixed(1)}s remaining` + else if ('elapsed' in this.format) subMessage = `${elapsed?.toFixed(1)}s elapsed` + else if ('rate' in this.format) subMessage = `${remaining.toFixed(1)}s remaining` return html`
@@ -115,7 +121,7 @@ export class ProgressBar extends LitElement { ${this.format.n} / ${this.format.total} (${percent.toFixed(1)}%)
- ${'elapsed' in this.format && 'rate' in this.format ? html`${this.format.elapsed?.toFixed(1)}s elapsed, ${remaining.toFixed(1)}s remaining` : ''} + ${subMessage ? html`${subMessage}` : ''}
`; diff --git a/src/renderer/src/stories/assets/dandi.svg b/src/renderer/src/stories/assets/dandi.svg index 23dcdd8d12..0b62d3ddb0 100644 --- a/src/renderer/src/stories/assets/dandi.svg +++ b/src/renderer/src/stories/assets/dandi.svg @@ -3,7 +3,7 @@ "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> Created by potrace 1.16, written by Peter Selinger 2001-2019 diff --git a/src/renderer/src/stories/assets/download.svg b/src/renderer/src/stories/assets/download.svg new file mode 100644 index 0000000000..52b961d454 --- /dev/null +++ b/src/renderer/src/stories/assets/download.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/stories/assets/exploration.svg b/src/renderer/src/stories/assets/exploration.svg index 2b5f3e8fc9..082dc98def 100644 --- a/src/renderer/src/stories/assets/exploration.svg +++ b/src/renderer/src/stories/assets/exploration.svg @@ -3,5 +3,5 @@ viewBox="0 -960 960 960" width="22" height="22" - style="margin-right: 28px; margin-bottom: -5px" + style="margin-right: 28px;" > diff --git a/src/renderer/src/stories/assets/info.svg b/src/renderer/src/stories/assets/info.svg new file mode 100644 index 0000000000..ffe9116464 --- /dev/null +++ b/src/renderer/src/stories/assets/info.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/src/stories/assets/inspect.svg b/src/renderer/src/stories/assets/inspect.svg index d788e86ff4..dcb66ff7af 100644 --- a/src/renderer/src/stories/assets/inspect.svg +++ b/src/renderer/src/stories/assets/inspect.svg @@ -3,5 +3,5 @@ xmlns="http://www.w3.org/2000/svg" height="25" viewBox="0 -960 960 960" width="25" -style="margin-right: 25px; margin-bottom: -5px" +style="margin-right: 25px;" > diff --git a/src/renderer/src/stories/assets/neurosift-logo.svg b/src/renderer/src/stories/assets/neurosift-logo.svg index 6886c10c28..90368db67b 100644 --- a/src/renderer/src/stories/assets/neurosift-logo.svg +++ b/src/renderer/src/stories/assets/neurosift-logo.svg @@ -6,7 +6,7 @@ xmlns="http://www.w3.org/2000/svg" height="22" width="22" - style="margin-right: 28px; margin-bottom: -5px" + style="margin-right: 28px;" viewBox="0 0 192.000000 192.000000" preserveAspectRatio="xMidYMid meet" > diff --git a/src/renderer/src/stories/assets/preview.svg b/src/renderer/src/stories/assets/preview.svg index 467b1d1868..192e9bada2 100644 --- a/src/renderer/src/stories/assets/preview.svg +++ b/src/renderer/src/stories/assets/preview.svg @@ -3,5 +3,5 @@ xmlns="http://www.w3.org/2000/svg" height="25" viewBox="0 -960 960 960" width="25" -style="margin-right: 30px; margin-bottom: -5px" +style="margin-right: 30px;" > diff --git a/src/renderer/src/stories/assets/settings.svg b/src/renderer/src/stories/assets/settings.svg index 273e062186..41928d8075 100644 --- a/src/renderer/src/stories/assets/settings.svg +++ b/src/renderer/src/stories/assets/settings.svg @@ -3,5 +3,5 @@ height="25" viewBox="0 -960 960 960" width="25" - style="margin-right: 25px; margin-bottom: -5px" + style="margin-right: 25px;" > diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index 7c40baedf0..ccd8c7d4f6 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -13,18 +13,22 @@ import { global, remove, save } from "../../../progress/index.js"; import { merge, setUndefinedIfNotDeclared } from "../utils.js"; import { homeDirectory, notyf, testDataFolderPath } from "../../../dependencies/globals.js"; -import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../electron/index.js"; +import { SERVER_FILE_PATH, electron, path, port, fs, onUpdateAvailable, onUpdateProgress, registerUpdateProgress } from "../../../electron/index.js"; import saveSVG from "../../assets/save.svg?raw"; import folderSVG from "../../assets/folder_open.svg?raw"; import deleteSVG from "../../assets/delete.svg?raw"; import generateSVG from "../../assets/restart.svg?raw"; +import downloadSVG from "../../assets/download.svg?raw"; +import infoSVG from "../../assets/info.svg?raw"; import { header } from "../../forms/utils"; import testingSuiteYaml from "../../../../../../guide_testing_suite.yml"; import { run } from "../guided-mode/options/utils.js"; import { joinPath } from "../../../globals.js"; +import { Modal } from "../../Modal"; +import { ProgressBar } from "../../ProgressBar"; const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); @@ -218,7 +222,87 @@ export class SettingsPage extends Page { this.#openNotyf(`Global settings changes saved.`, "success"); }; + + #releaseNotesModal; + + + // Populate the Update Available display + updated() { + + const updateDiv = this.querySelector('#update-available') + + if (updateDiv.innerHTML) return // Only populate once + + onUpdateAvailable(( updateInfo ) => { + + const container = document.createElement('div') + container.classList.add('update-container') + + const mainUpdateInfo = document.createElement('div') + + const infoIcon = document.createElement('slot') + infoIcon.innerHTML = infoSVG + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return this.#releaseNotesModal.open = true + + const modal = this.#releaseNotesModal = new Modal({ header: `Release Notes` }) + + const releaseNotes = document.createElement('div') + releaseNotes.style.padding = '25px' + releaseNotes.innerHTML = updateInfo.releaseNotes + modal.append(releaseNotes) + + document.body.append(modal) + + modal.open = true + } + + const controls = document.createElement('div') + controls.classList.add('controls') + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update`, + size: 'extra-small', + onClick: () => electron.ipcRenderer.send('download-update') + }) + + controls.append(downloadButton) + + + const header = document.createElement('div') + header.classList.add('header') + + const title = document.createElement('h4') + title.innerText = `NWB GUIDE ${updateInfo.version}` + header.append(title, infoIcon) + + const description = document.createElement('span') + description.innerText = `A new version of the application is available.` + + mainUpdateInfo.append(header, description) + + container.append(mainUpdateInfo, controls) + + let progressBarEl; + onUpdateProgress(( progress ) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar() + const hr = document.createElement('hr') + updateDiv.append(hr, progressBarEl) + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress + } + }) + updateDiv.append(container) + + }) + } + render() { + this.localState = structuredClone(global.data); // NOTE: API Keys and Dandiset IDs persist across selected project @@ -337,6 +421,7 @@ export class SettingsPage extends Page { +


${this.form} diff --git a/src/renderer/src/stories/sidebar.js b/src/renderer/src/stories/sidebar.js index 8d3cd6303d..3ab1b8d83c 100644 --- a/src/renderer/src/stories/sidebar.js +++ b/src/renderer/src/stories/sidebar.js @@ -181,7 +181,17 @@ export class Sidebar extends LitElement { const a = document.createElement("a"); a.setAttribute("data-id", id); a.href = "#"; - a.innerHTML = `${icon} ${label}`; + + a.insertAdjacentHTML("afterbegin", icon); + + const labelContainer = document.createElement("div"); + const labelEl = document.createElement("span"); + labelEl.innerHTML = label; + labelContainer.append(labelEl); + + a.append(labelContainer); + + a.onclick = () => this.#onClick(id); const li = document.createElement("li"); From 6e1d1094ba10691f42ef77caf931cdfec6d9d862 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 May 2024 20:25:09 +0000 Subject: [PATCH 02/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/main/main.ts | 2 +- src/renderer/assets/css/custom.css | 6 +- src/renderer/assets/css/nav.css | 1 - src/renderer/src/electron/index.js | 26 +++-- src/renderer/src/stories/assets/download.svg | 2 +- src/renderer/src/stories/assets/info.svg | 2 +- .../stories/pages/settings/SettingsPage.js | 102 +++++++++--------- src/renderer/src/stories/sidebar.js | 1 - 8 files changed, 69 insertions(+), 73 deletions(-) diff --git a/src/main/main.ts b/src/main/main.ts index 1d7583f8ff..00804654b7 100755 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -359,7 +359,7 @@ function initialize() { createWindow(); // Check for updates - autoUpdater.checkForUpdates(); + autoUpdater.checkForUpdates(); // Clear ready queue readyQueue.forEach(f => onWindowReady(f)) diff --git a/src/renderer/assets/css/custom.css b/src/renderer/assets/css/custom.css index 82335470a6..f1b52c3a4c 100644 --- a/src/renderer/assets/css/custom.css +++ b/src/renderer/assets/css/custom.css @@ -1,4 +1,3 @@ - /* Update Notification Box */ #update-available:not(:empty) { margin-top: 15px; @@ -9,7 +8,6 @@ border-radius: 5px; } - #update-available .update-container { display: flex; align-items: center; @@ -25,7 +23,7 @@ border-radius: 50%; } -#update-available .header { +#update-available .header { display: flex; align-items: center; gap: 5px; @@ -50,4 +48,4 @@ margin-left: auto; display: flex; gap: 10px; -} \ No newline at end of file +} diff --git a/src/renderer/assets/css/nav.css b/src/renderer/assets/css/nav.css index f449fe5a21..c6b0e36be4 100755 --- a/src/renderer/assets/css/nav.css +++ b/src/renderer/assets/css/nav.css @@ -156,7 +156,6 @@ a[data-toggle="collapse"] { padding: 10px; } - #main-nav .sidebar-body a { font-size: 14px; display: flex; diff --git a/src/renderer/src/electron/index.js b/src/renderer/src/electron/index.js index 12c0feeef0..eef45fab1b 100644 --- a/src/renderer/src/electron/index.js +++ b/src/renderer/src/electron/index.js @@ -14,13 +14,12 @@ export let path = null; export let log = null; export let crypto = null; - let updateAvailable = false; const updateAvailableCallbacks = []; export const onUpdateAvailable = (callback) => { if (updateAvailable) callback(updateAvailable); else updateAvailableCallbacks.push(callback); -} +}; let updateProgress = null; @@ -28,19 +27,18 @@ const updateProgressCallbacks = []; export const onUpdateProgress = (callback) => { if (updateProgress) callback(updateProgress); else updateProgressCallbacks.push(callback); -} +}; export const registerUpdateProgress = (info) => { updateProgress = info; updateProgressCallbacks.forEach((cb) => cb(info)); -} +}; const registerUpdate = (info) => { - updateAvailable = info - document.body.setAttribute('data-update-available', JSON.stringify(info)) - updateAvailableCallbacks.forEach((cb) => cb(info)) -} - + updateAvailable = info; + document.body.setAttribute("data-update-available", JSON.stringify(info)); + updateAvailableCallbacks.forEach((cb) => cb(info)); +}; // Used in tests try { @@ -67,14 +65,14 @@ if (isElectron) { electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) ); - electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)) + electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)); - electron.ipcRenderer.on(`update-available`, (_, info) => info ? registerUpdate(info) : '') + electron.ipcRenderer.on(`update-available`, (_, info) => (info ? registerUpdate(info) : "")); - electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)) - electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)) + electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)); + electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)); - electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)) + electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)); port = electron.ipcRenderer.sendSync("get-port"); console.log("User OS:", os.type(), os.platform(), "version:", os.release()); diff --git a/src/renderer/src/stories/assets/download.svg b/src/renderer/src/stories/assets/download.svg index 52b961d454..7b794bd408 100644 --- a/src/renderer/src/stories/assets/download.svg +++ b/src/renderer/src/stories/assets/download.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/renderer/src/stories/assets/info.svg b/src/renderer/src/stories/assets/info.svg index ffe9116464..9dfdef6d67 100644 --- a/src/renderer/src/stories/assets/info.svg +++ b/src/renderer/src/stories/assets/info.svg @@ -1 +1 @@ - \ No newline at end of file + diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index ccd8c7d4f6..c2d5c35eae 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -13,7 +13,16 @@ import { global, remove, save } from "../../../progress/index.js"; import { merge, setUndefinedIfNotDeclared } from "../utils.js"; import { homeDirectory, notyf, testDataFolderPath } from "../../../dependencies/globals.js"; -import { SERVER_FILE_PATH, electron, path, port, fs, onUpdateAvailable, onUpdateProgress, registerUpdateProgress } from "../../../electron/index.js"; +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, + onUpdateAvailable, + onUpdateProgress, + registerUpdateProgress, +} from "../../../electron/index.js"; import saveSVG from "../../assets/save.svg?raw"; import folderSVG from "../../assets/folder_open.svg?raw"; @@ -222,87 +231,80 @@ export class SettingsPage extends Page { this.#openNotyf(`Global settings changes saved.`, "success"); }; - #releaseNotesModal; - // Populate the Update Available display updated() { - - const updateDiv = this.querySelector('#update-available') - - if (updateDiv.innerHTML) return // Only populate once + const updateDiv = this.querySelector("#update-available"); - onUpdateAvailable(( updateInfo ) => { + if (updateDiv.innerHTML) return; // Only populate once - const container = document.createElement('div') - container.classList.add('update-container') + onUpdateAvailable((updateInfo) => { + const container = document.createElement("div"); + container.classList.add("update-container"); - const mainUpdateInfo = document.createElement('div') + const mainUpdateInfo = document.createElement("div"); - const infoIcon = document.createElement('slot') - infoIcon.innerHTML = infoSVG + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; infoIcon.onclick = () => { - if (this.#releaseNotesModal) return this.#releaseNotesModal.open = true + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); - const modal = this.#releaseNotesModal = new Modal({ header: `Release Notes` }) + const modal = (this.#releaseNotesModal = new Modal({ header: `Release Notes` })); - const releaseNotes = document.createElement('div') - releaseNotes.style.padding = '25px' - releaseNotes.innerHTML = updateInfo.releaseNotes - modal.append(releaseNotes) + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); - document.body.append(modal) + document.body.append(modal); - modal.open = true - } + modal.open = true; + }; - const controls = document.createElement('div') - controls.classList.add('controls') + const controls = document.createElement("div"); + controls.classList.add("controls"); const downloadButton = new Button({ icon: downloadSVG, label: `Update`, - size: 'extra-small', - onClick: () => electron.ipcRenderer.send('download-update') - }) + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); - controls.append(downloadButton) + controls.append(downloadButton); + const header = document.createElement("div"); + header.classList.add("header"); - const header = document.createElement('div') - header.classList.add('header') + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); - const title = document.createElement('h4') - title.innerText = `NWB GUIDE ${updateInfo.version}` - header.append(title, infoIcon) - - const description = document.createElement('span') - description.innerText = `A new version of the application is available.` - - mainUpdateInfo.append(header, description) + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; - container.append(mainUpdateInfo, controls) + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); let progressBarEl; - onUpdateProgress(( progress ) => { + onUpdateProgress((progress) => { if (!progressBarEl) { - progressBarEl = new ProgressBar() - const hr = document.createElement('hr') - updateDiv.append(hr, progressBarEl) + progressBarEl = new ProgressBar(); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); } progressBarEl.format = { prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress - } - }) - updateDiv.append(container) - - }) + ...progress, + }; + }); + updateDiv.append(container); + }); } render() { - this.localState = structuredClone(global.data); // NOTE: API Keys and Dandiset IDs persist across selected project diff --git a/src/renderer/src/stories/sidebar.js b/src/renderer/src/stories/sidebar.js index 3ab1b8d83c..e98c7d07cf 100644 --- a/src/renderer/src/stories/sidebar.js +++ b/src/renderer/src/stories/sidebar.js @@ -191,7 +191,6 @@ export class Sidebar extends LitElement { a.append(labelContainer); - a.onclick = () => this.#onClick(id); const li = document.createElement("li"); From 5078d88bd053feb9a979ce626959f92edea981ad Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Mon, 20 May 2024 15:19:04 -0700 Subject: [PATCH 03/18] Intelligently render bytes --- src/renderer/assets/css/custom.css | 2 +- src/renderer/src/stories/ProgressBar.ts | 35 +++++++++++++++++-- .../stories/pages/settings/SettingsPage.js | 16 +++++++-- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/renderer/assets/css/custom.css b/src/renderer/assets/css/custom.css index f1b52c3a4c..55aa13940e 100644 --- a/src/renderer/assets/css/custom.css +++ b/src/renderer/assets/css/custom.css @@ -1,7 +1,7 @@ /* Update Notification Box */ #update-available:not(:empty) { margin-top: 15px; - background-color: #f8d2b5; + background-color: #ffe5d2; color: black; padding: 15px 10px; border: 1px solid #f6c39c; diff --git a/src/renderer/src/stories/ProgressBar.ts b/src/renderer/src/stories/ProgressBar.ts index d9afd5af48..5519bbf269 100644 --- a/src/renderer/src/stories/ProgressBar.ts +++ b/src/renderer/src/stories/ProgressBar.ts @@ -5,13 +5,37 @@ import { LitElement, html, css, unsafeCSS } from 'lit'; export type ProgressProps = { size?: string, + isBytes?: boolean, + format?: { n: number, total: number, [key: string]: any, - }, + } +} + +export function humanReadableBytes(size: number | string) { + + // Define the units + const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + // Initialize the index to 0 + let index = 0; + + // Convert the size to a floating point number + size = parseFloat(size); + + // Loop until the size is less than 1024 and increment the unit + while (size >= 1024 && index < units.length - 1) { + size /= 1024; + index += 1; + } + + // Return the size formatted with 2 decimal places and the appropriate unit + return `${size.toFixed(2)} ${units[index]}`; } + const animationDuration = 500 // ms export class ProgressBar extends LitElement { @@ -88,12 +112,14 @@ export class ProgressBar extends LitElement { declare format: any declare size: string + declare isBytes: boolean constructor(props: ProgressProps = {}) { super(); this.size = props.size ?? 'medium' this.format = props.format ?? {} + this.isBytes = props.isBytes ?? false if (!('n' in this.format)) this.format.n = 0 if (!('total' in this.format)) this.format.total = 0 } @@ -102,6 +128,11 @@ export class ProgressBar extends LitElement { const percent = this.format.total ? 100 * (this.format.n / this.format.total) : 0; const remaining = this.format.rate && this.format.total ? (this.format.total - this.format.n) / this.format.rate : 0; // Seconds + + const numerator = this.isBytes ? humanReadableBytes(this.format.n) : this.format.n + const denominator = this.isBytes ? humanReadableBytes(this.format.total) : this.format.total + + const elapsed = this.format.elapsed let subMessage = '' @@ -118,7 +149,7 @@ export class ProgressBar extends LitElement {
- ${this.format.n} / ${this.format.total} (${percent.toFixed(1)}%) + ${numerator} / ${denominator} (${percent.toFixed(1)}%)
${subMessage ? html`${subMessage}` : ''} diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index c2d5c35eae..9276f7024a 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -37,7 +37,7 @@ import testingSuiteYaml from "../../../../../../guide_testing_suite.yml"; import { run } from "../guided-mode/options/utils.js"; import { joinPath } from "../../../globals.js"; import { Modal } from "../../Modal"; -import { ProgressBar } from "../../ProgressBar"; +import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); @@ -240,6 +240,13 @@ export class SettingsPage extends Page { if (updateDiv.innerHTML) return; // Only populate once onUpdateAvailable((updateInfo) => { + + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + const container = document.createElement("div"); container.classList.add("update-container"); @@ -267,7 +274,7 @@ export class SettingsPage extends Page { controls.classList.add("controls"); const downloadButton = new Button({ icon: downloadSVG, - label: `Update`, + label: `Update (${humanReadableBytes(filesize)})`, size: "extra-small", onClick: () => electron.ipcRenderer.send("download-update"), }); @@ -291,7 +298,10 @@ export class SettingsPage extends Page { let progressBarEl; onUpdateProgress((progress) => { if (!progressBarEl) { - progressBarEl = new ProgressBar(); + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize } + }); const hr = document.createElement("hr"); updateDiv.append(hr, progressBarEl); } From 3aa665f9a4f10a8d40656f8fa91e11f2b243a512 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 May 2024 22:19:22 +0000 Subject: [PATCH 04/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/renderer/src/stories/pages/settings/SettingsPage.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index 9276f7024a..9fa0d08bff 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -240,10 +240,9 @@ export class SettingsPage extends Page { if (updateDiv.innerHTML) return; // Only populate once onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - const relativePath = updateInfo.path + const relativePath = updateInfo.path; const file = updateInfo.files.find((f) => f.url === relativePath); const filesize = file.size; @@ -300,7 +299,7 @@ export class SettingsPage extends Page { if (!progressBarEl) { progressBarEl = new ProgressBar({ isBytes: true, - format: { total: filesize } + format: { total: filesize }, }); const hr = document.createElement("hr"); updateDiv.append(hr, progressBarEl); From 1ed277b1082203ca6d23c1dc3f6d76b321c03789 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 18:26:30 +0000 Subject: [PATCH 05/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/electron/frontend/assets/css/custom.css | 102 +- src/electron/frontend/assets/css/nav.css | 992 +++++++++--------- .../frontend/core/components/Dashboard.js | 908 ++++++++-------- .../components/pages/settings/SettingsPage.js | 930 ++++++++-------- .../frontend/core/components/sidebar.js | 514 +++++---- src/electron/frontend/core/pages.js | 390 +++---- src/electron/frontend/utils/electron.js | 184 ++-- 7 files changed, 1966 insertions(+), 2054 deletions(-) diff --git a/src/electron/frontend/assets/css/custom.css b/src/electron/frontend/assets/css/custom.css index 2b1e6b78ce..8b422f30c6 100644 --- a/src/electron/frontend/assets/css/custom.css +++ b/src/electron/frontend/assets/css/custom.css @@ -1,51 +1,51 @@ -/* Update Notification Box */ -#update-available:not(:empty) { - margin-top: 15px; - background-color: #ffe5d2; - color: black; - padding: 15px 10px; - border: 1px solid #f6c39c; - border-radius: 5px; -} - -#update-available .update-container { - display: flex; - align-items: center; - gap: 15px; -} - -#update-available .update-container::before { - content: ""; - width: 6px; - height: 6px; - margin-left: 5px; - background-color: #d17128; - border-radius: 50%; -} - -#update-available .header { - display: flex; - align-items: center; - gap: 5px; -} - -#update-available h4 { - margin: 0; -} - -#update-available span { - color: gray; - font-size: 80%; -} - -#update-available .header svg { - height: 15px; - width: auto; - cursor: pointer; -} - -#update-available .controls { - margin-left: auto; - display: flex; - gap: 10px; -} +/* Update Notification Box */ +#update-available:not(:empty) { + margin-top: 15px; + background-color: #ffe5d2; + color: black; + padding: 15px 10px; + border: 1px solid #f6c39c; + border-radius: 5px; +} + +#update-available .update-container { + display: flex; + align-items: center; + gap: 15px; +} + +#update-available .update-container::before { + content: ""; + width: 6px; + height: 6px; + margin-left: 5px; + background-color: #d17128; + border-radius: 50%; +} + +#update-available .header { + display: flex; + align-items: center; + gap: 5px; +} + +#update-available h4 { + margin: 0; +} + +#update-available span { + color: gray; + font-size: 80%; +} + +#update-available .header svg { + height: 15px; + width: auto; + cursor: pointer; +} + +#update-available .controls { + margin-left: auto; + display: flex; + gap: 10px; +} diff --git a/src/electron/frontend/assets/css/nav.css b/src/electron/frontend/assets/css/nav.css index d994991434..a5a15982c9 100755 --- a/src/electron/frontend/assets/css/nav.css +++ b/src/electron/frontend/assets/css/nav.css @@ -1,498 +1,494 @@ -/* Nav bootstrap */ -/* toggle button */ -#sidebarCollapse { - width: 35px; - height: 35px; - border-radius: 50%; - background: transparent; - position: absolute; - top: 8px; - left: 200px; - cursor: pointer; - border: none; - z-index: 2; - transition: all 0.25s linear; -} - -#sidebarCollapse span { - width: 80%; - height: 2px; - margin: 0 auto; - display: block; - background: var(--color-light-green); - transition: all 0.25s linear; - /* transition: all 0.1s cubic-bezier(0.81, -0.33, 0.345, 1.375); */ -} -/* animate toggle button */ -#sidebarCollapse span:first-of-type { - transition: all 0.25s linear; - transform: rotate(45deg) translate(2px, 2px); -} -#sidebarCollapse span:nth-of-type(2) { - transition: all 0.25s linear; - opacity: 0; -} -#sidebarCollapse span:last-of-type { - transition: all 0.25s linear; - transform: rotate(-45deg) translate(1px, -1px); -} - -#sidebarCollapse.active span { - transition: all 0.25s linear; - transform: none; - opacity: 1; - margin: 5px auto; -} - -#main-nav { - background: var(--color-sidebar); - border-top: 1px solid #d5d5d5; - color: black; - font-family: "Source Sans Pro", sans-serif; - transition: all 0.25s linear; - transform-origin: 0 50%; /* Set the transformed position of sidebar to center left side. */ -} - -#main-nav.active { - width: 0px; - overflow: hidden; - /* transform: rotateY(150deg); */ -} - -.navbar-btn { - transition: margin-left 600ms ease; -} - -.navbar-btn.active { - margin-left: -190px; - transition: all 0.25s linear; -} - -.navbar-btn:focus { - outline: none; -} - -.navbar-btn.active:focus { - outline: none; -} - -.dash-content.active { - margin-left: -230px; -} - -a[data-toggle="collapse"] { - position: relative; -} - -.dropdown-toggle::after { - display: block; - position: absolute; - top: 50%; - right: 20px; - transform: translateY(-50%); -} - -#main-nav { - height: 100%; -} - -#nav-items { - height: 100%; - display: flex; - flex-direction: column; -} - -#main-nav .sidebar-header { - padding: 20px; - padding-bottom: 0px; -} - -#main-nav .sidebar-header img { - cursor: pointer; -} - -#main-nav .sidebar-body { - display: flex; - flex-direction: column; - justify-content: space-between; - overflow-y: auto; - margin-top: 15px; - flex-grow: 1; - border-top: 1px solid #8f8f8f; -} - -#main-nav .sidebar-body > *:last-child { - border-top: 1px solid #8f8f8f; - padding-top: 10px; - position: sticky; - bottom: 0; - background: var(--color-sidebar); -} - -#main-nav ul.components { - background: var(--color-sidebar); - padding: 3px; - padding-top: 8px; -} - -#main-nav .sidebar-body > *:last-child h4 { - margin-top: 0px !important; -} - -#main-nav .sidebar-body li { - list-style: none; -} - -#main-nav ul { - padding-right: 10px; - padding-left: 3px; - margin-right: 0; - margin-bottom: 0; - overflow-y: auto; -} - -#main-nav ul p { - color: #000; - padding: 10px; -} - -#main-nav .sidebar-body a { - font-size: 14px; - display: flex; - align-items: center; - position: relative; - padding: 15px 0px; - padding-left: 20px; - margin-bottom: 5px; - text-align: left; - padding-right: 10px; - color: #000; - border: none; - border-radius: 4px; - border-left: 4px solid transparent; -} - -[data-update-available] [data-id="settings"] > div { - display: flex; - flex-direction: column; -} - -[data-update-available] [data-id="settings"] > div::after { - content: "Update Available"; - color: gray; - font-size: 70%; - font-weight: normal; - line-height: 1.5; -} - -#main-nav .sidebar-body svg { - fill: #000; -} - -#main-nav .sidebar-body a a { - padding-left: 10px; - text-align: left; - padding-right: 10px; -} - -#main-nav .sidebar-body a i { - margin-right: 25px; - font-size: 20px; -} - -#main-nav .sidebar-body a:hover { - text-decoration: none; - background: none; - font-weight: 600; -} - -#main-nav .sidebar-body a.is-selected { - color: var(--color-light-green); - background: none; - font-weight: 600; - border-left: 4px solid var(--color-light-green); - /* margin-left: -3px; */ - border-radius: 0; -} - -#main-nav .sidebar-body a.is-selected svg { - fill: var(--color-light-green); -} - -.help-section { - bottom: 2px; - position: absolute; - width: 230px; -} - -.help-section ul { - padding-left: 15px !important; -} - -.help-section a { - text-decoration: none; - line-height: 5px; - border: none; - color: #f0f0f0; - width: 35px !important; - padding-right: 3px !important; - padding-left: 3px !important; - z-index: 200; -} - -.help-section a i { - font-size: 17px; - opacity: 0.7; -} - -.help-section a:hover { - background: none !important; - border: none !important; -} - -.help-section a:hover i { - opacity: 1; -} - -.help-section a.is-selected { - color: #000 !important; - background: none !important; - border: none !important; -} - -.list-unstyled { - list-style: none; - border-bottom: none; -} - -.list-unstyled.components li a { - -webkit-user-drag: none; -} - -.collapse:not(.show) { - display: none; -} - -.collapse.show { - display: block; -} - -.collapsing { - position: relative; - height: 0; - overflow: hidden; - -webkit-transition: height 0.35s ease; - -o-transition: height 0.35s ease; - transition: height 0.35s ease; -} -@media (prefers-reduced-motion: reduce) { - .collapsing { - -webkit-transition: none; - -o-transition: none; - transition: none; - } -} - -.nav { - padding: 0px 0px; - /* position: fixed; */ - width: 240px; - min-height: 100vh; - color: var(--color-subtle); - visibility: visible; - left: 0; - z-index: 1; - align-items: stretch; - /* display: flex; */ - transition: 0.5s; -} - -@media screen and (max-height: 500px) { - #main-nav { - padding-top: 15px; - } - #main-nav a { - font-size: 13px; - } -} - -.nav.is-shown { - visibility: visible; - opacity: 1; -} - -.nav-header { - position: relative; - padding: 10px 10px; - margin-top: 10px; - margin-bottom: 30px; -} - -.nav-title strong { - color: var(--color-light-green); - opacity: 0.8; - transition: color 0.1s ease-in; -} - -.nav-title strong:hover { - color: linear-gradient( - 90deg, - rgba(37, 129, 147, 1) 0%, - rgba(52, 207, 196, 1) 51% - ); -} - -.nav-header-icon { - position: absolute; - width: 165px; - height: 70px; - top: 1.3rem; - right: 1.8rem; -} - -.nav-item { - width: 240px !important; -} - -.nav-icon { - width: 30px; - height: 30px; - margin-right: 27px; - padding-bottom: 1px; - padding-top: 1px; - margin-left: -22px; - margin-top: 10px; - margin-bottom: 10px; - vertical-align: middle; -} - -.nav-icon.logo { - width: 45px; - height: 45px; - margin-right: 24px; - margin-left: 15px; - margin-bottom: 75px; - vertical-align: middle; -} - -.nav-video { - width: 18px; - height: 21px; - vertical-align: sub; - text-decoration: none; -} - -.nav-category { - margin: 0.2em 0; - padding-left: 2rem; - font-size: 11px; - font-weight: normal; - text-transform: uppercase; -} - -.nav-button { - display: block; - width: 100%; - padding: 0.5rem; - padding-left: calc(5rem + 5px + 0.5rem); /* padding + icon + magic */ - padding-top: 0.8rem; - padding-bottom: 0.8rem; - line-height: 2; - text-align: left; - font-size: 16px; - color: white; - border: none; - background-color: transparent; - outline: none; - opacity: 0.8; - cursor: pointer; - font-family: "Open Sans", sans-serif; - background-size: 30px 30px; - background-repeat: no-repeat; - background-position: 22px center; -} - -.nav-button:hover, -.nav-button:focus:not(.is-selected) { - background-color: hsla(0, 0%, 0%, 0.1); - color: white; - opacity: 1; -} - -.nav-button.is-selected { - background-color: var(--color-accent); -} - -.nav-button.is-selected, -.nav-button.is-selected em { - color: white; - font-weight: 500; - opacity: 1; -} - -.nav-button.is-selected:focus { - opacity: 1; -} - -.nav-button em { - font-style: normal; - font-weight: 600; - color: var(--color-strong); - pointer-events: none; /* makes it invisible to clicks */ -} - -.nav-footer { - margin-top: 1rem; - padding: 2rem; - border-top: 1px solid var(--color-border); - text-align: center; -} - -.nav-footer-icon { - width: calc(770px / 6.5); - height: calc(88px / 6.5); -} - -.nav-footer a { - outline: none; -} - -.nav-footer-button { - display: block; - width: 100%; - padding: 0; - margin-bottom: 0.75rem; - line-height: 2; - text-align: left; - font: inherit; - font-size: 15px; - color: inherit; - border: none; - background-color: transparent; - cursor: default; - outline: none; - text-align: center; -} - -.nav-footer-button:focus { - color: var(--color-strong); -} - -.nav-footer-logo { - color: hsl(0, 0%, 66%); -} - -.nav-footer-logo:focus { - color: hsl(0, 0%, 33%); -} - -/* Remove border on the logo */ -.nav-footer-logo.nav-footer-logo { - border-bottom: none; -} - -.nav-center-logo-image { - display: block; - width: 100%; - padding: 0px 25px; -} +/* Nav bootstrap */ +/* toggle button */ +#sidebarCollapse { + width: 35px; + height: 35px; + border-radius: 50%; + background: transparent; + position: absolute; + top: 8px; + left: 200px; + cursor: pointer; + border: none; + z-index: 2; + transition: all 0.25s linear; +} + +#sidebarCollapse span { + width: 80%; + height: 2px; + margin: 0 auto; + display: block; + background: var(--color-light-green); + transition: all 0.25s linear; + /* transition: all 0.1s cubic-bezier(0.81, -0.33, 0.345, 1.375); */ +} +/* animate toggle button */ +#sidebarCollapse span:first-of-type { + transition: all 0.25s linear; + transform: rotate(45deg) translate(2px, 2px); +} +#sidebarCollapse span:nth-of-type(2) { + transition: all 0.25s linear; + opacity: 0; +} +#sidebarCollapse span:last-of-type { + transition: all 0.25s linear; + transform: rotate(-45deg) translate(1px, -1px); +} + +#sidebarCollapse.active span { + transition: all 0.25s linear; + transform: none; + opacity: 1; + margin: 5px auto; +} + +#main-nav { + background: var(--color-sidebar); + border-top: 1px solid #d5d5d5; + color: black; + font-family: "Source Sans Pro", sans-serif; + transition: all 0.25s linear; + transform-origin: 0 50%; /* Set the transformed position of sidebar to center left side. */ +} + +#main-nav.active { + width: 0px; + overflow: hidden; + /* transform: rotateY(150deg); */ +} + +.navbar-btn { + transition: margin-left 600ms ease; +} + +.navbar-btn.active { + margin-left: -190px; + transition: all 0.25s linear; +} + +.navbar-btn:focus { + outline: none; +} + +.navbar-btn.active:focus { + outline: none; +} + +.dash-content.active { + margin-left: -230px; +} + +a[data-toggle="collapse"] { + position: relative; +} + +.dropdown-toggle::after { + display: block; + position: absolute; + top: 50%; + right: 20px; + transform: translateY(-50%); +} + +#main-nav { + height: 100%; +} + +#nav-items { + height: 100%; + display: flex; + flex-direction: column; +} + +#main-nav .sidebar-header { + padding: 20px; + padding-bottom: 0px; +} + +#main-nav .sidebar-header img { + cursor: pointer; +} + +#main-nav .sidebar-body { + display: flex; + flex-direction: column; + justify-content: space-between; + overflow-y: auto; + margin-top: 15px; + flex-grow: 1; + border-top: 1px solid #8f8f8f; +} + +#main-nav .sidebar-body > *:last-child { + border-top: 1px solid #8f8f8f; + padding-top: 10px; + position: sticky; + bottom: 0; + background: var(--color-sidebar); +} + +#main-nav ul.components { + background: var(--color-sidebar); + padding: 3px; + padding-top: 8px; +} + +#main-nav .sidebar-body > *:last-child h4 { + margin-top: 0px !important; +} + +#main-nav .sidebar-body li { + list-style: none; +} + +#main-nav ul { + padding-right: 10px; + padding-left: 3px; + margin-right: 0; + margin-bottom: 0; + overflow-y: auto; +} + +#main-nav ul p { + color: #000; + padding: 10px; +} + +#main-nav .sidebar-body a { + font-size: 14px; + display: flex; + align-items: center; + position: relative; + padding: 15px 0px; + padding-left: 20px; + margin-bottom: 5px; + text-align: left; + padding-right: 10px; + color: #000; + border: none; + border-radius: 4px; + border-left: 4px solid transparent; +} + +[data-update-available] [data-id="settings"] > div { + display: flex; + flex-direction: column; +} + +[data-update-available] [data-id="settings"] > div::after { + content: "Update Available"; + color: gray; + font-size: 70%; + font-weight: normal; + line-height: 1.5; +} + +#main-nav .sidebar-body svg { + fill: #000; +} + +#main-nav .sidebar-body a a { + padding-left: 10px; + text-align: left; + padding-right: 10px; +} + +#main-nav .sidebar-body a i { + margin-right: 25px; + font-size: 20px; +} + +#main-nav .sidebar-body a:hover { + text-decoration: none; + background: none; + font-weight: 600; +} + +#main-nav .sidebar-body a.is-selected { + color: var(--color-light-green); + background: none; + font-weight: 600; + border-left: 4px solid var(--color-light-green); + /* margin-left: -3px; */ + border-radius: 0; +} + +#main-nav .sidebar-body a.is-selected svg { + fill: var(--color-light-green); +} + +.help-section { + bottom: 2px; + position: absolute; + width: 230px; +} + +.help-section ul { + padding-left: 15px !important; +} + +.help-section a { + text-decoration: none; + line-height: 5px; + border: none; + color: #f0f0f0; + width: 35px !important; + padding-right: 3px !important; + padding-left: 3px !important; + z-index: 200; +} + +.help-section a i { + font-size: 17px; + opacity: 0.7; +} + +.help-section a:hover { + background: none !important; + border: none !important; +} + +.help-section a:hover i { + opacity: 1; +} + +.help-section a.is-selected { + color: #000 !important; + background: none !important; + border: none !important; +} + +.list-unstyled { + list-style: none; + border-bottom: none; +} + +.list-unstyled.components li a { + -webkit-user-drag: none; +} + +.collapse:not(.show) { + display: none; +} + +.collapse.show { + display: block; +} + +.collapsing { + position: relative; + height: 0; + overflow: hidden; + -webkit-transition: height 0.35s ease; + -o-transition: height 0.35s ease; + transition: height 0.35s ease; +} +@media (prefers-reduced-motion: reduce) { + .collapsing { + -webkit-transition: none; + -o-transition: none; + transition: none; + } +} + +.nav { + padding: 0px 0px; + /* position: fixed; */ + width: 240px; + min-height: 100vh; + color: var(--color-subtle); + visibility: visible; + left: 0; + z-index: 1; + align-items: stretch; + /* display: flex; */ + transition: 0.5s; +} + +@media screen and (max-height: 500px) { + #main-nav { + padding-top: 15px; + } + #main-nav a { + font-size: 13px; + } +} + +.nav.is-shown { + visibility: visible; + opacity: 1; +} + +.nav-header { + position: relative; + padding: 10px 10px; + margin-top: 10px; + margin-bottom: 30px; +} + +.nav-title strong { + color: var(--color-light-green); + opacity: 0.8; + transition: color 0.1s ease-in; +} + +.nav-title strong:hover { + color: linear-gradient(90deg, rgba(37, 129, 147, 1) 0%, rgba(52, 207, 196, 1) 51%); +} + +.nav-header-icon { + position: absolute; + width: 165px; + height: 70px; + top: 1.3rem; + right: 1.8rem; +} + +.nav-item { + width: 240px !important; +} + +.nav-icon { + width: 30px; + height: 30px; + margin-right: 27px; + padding-bottom: 1px; + padding-top: 1px; + margin-left: -22px; + margin-top: 10px; + margin-bottom: 10px; + vertical-align: middle; +} + +.nav-icon.logo { + width: 45px; + height: 45px; + margin-right: 24px; + margin-left: 15px; + margin-bottom: 75px; + vertical-align: middle; +} + +.nav-video { + width: 18px; + height: 21px; + vertical-align: sub; + text-decoration: none; +} + +.nav-category { + margin: 0.2em 0; + padding-left: 2rem; + font-size: 11px; + font-weight: normal; + text-transform: uppercase; +} + +.nav-button { + display: block; + width: 100%; + padding: 0.5rem; + padding-left: calc(5rem + 5px + 0.5rem); /* padding + icon + magic */ + padding-top: 0.8rem; + padding-bottom: 0.8rem; + line-height: 2; + text-align: left; + font-size: 16px; + color: white; + border: none; + background-color: transparent; + outline: none; + opacity: 0.8; + cursor: pointer; + font-family: "Open Sans", sans-serif; + background-size: 30px 30px; + background-repeat: no-repeat; + background-position: 22px center; +} + +.nav-button:hover, +.nav-button:focus:not(.is-selected) { + background-color: hsla(0, 0%, 0%, 0.1); + color: white; + opacity: 1; +} + +.nav-button.is-selected { + background-color: var(--color-accent); +} + +.nav-button.is-selected, +.nav-button.is-selected em { + color: white; + font-weight: 500; + opacity: 1; +} + +.nav-button.is-selected:focus { + opacity: 1; +} + +.nav-button em { + font-style: normal; + font-weight: 600; + color: var(--color-strong); + pointer-events: none; /* makes it invisible to clicks */ +} + +.nav-footer { + margin-top: 1rem; + padding: 2rem; + border-top: 1px solid var(--color-border); + text-align: center; +} + +.nav-footer-icon { + width: calc(770px / 6.5); + height: calc(88px / 6.5); +} + +.nav-footer a { + outline: none; +} + +.nav-footer-button { + display: block; + width: 100%; + padding: 0; + margin-bottom: 0.75rem; + line-height: 2; + text-align: left; + font: inherit; + font-size: 15px; + color: inherit; + border: none; + background-color: transparent; + cursor: default; + outline: none; + text-align: center; +} + +.nav-footer-button:focus { + color: var(--color-strong); +} + +.nav-footer-logo { + color: hsl(0, 0%, 66%); +} + +.nav-footer-logo:focus { + color: hsl(0, 0%, 33%); +} + +/* Remove border on the logo */ +.nav-footer-logo.nav-footer-logo { + border-bottom: none; +} + +.nav-center-logo-image { + display: block; + width: 100%; + padding: 0px 25px; +} diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index a7db8b2933..de5aeb61d7 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -1,466 +1,442 @@ -import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; - -import { Main, checkIfPageIsSkipped } from "./Main.js"; -import { Sidebar } from "./sidebar.js"; -import { NavigationSidebar } from "./NavigationSidebar.js"; - -// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) -import "../../assets/css/custom.css"; - -// Global styles to apply with the dashboard -import "../../assets/css/variables.css"; -import "../../assets/css/nativize.css"; -import "../../assets/css/global.css"; -import "../../assets/css/nav.css"; -import "../../assets/css/section.css"; -import "../../assets/css/demo.css"; -import "../../assets/css/individualtab.css"; -import "../../assets/css/main_tabs.css"; -// import "../../node_modules/cropperjs/dist/cropper.css" -import "../../../../node_modules/notyf/notyf.min.css"; -import "../../assets/css/spur.css"; -import "../../assets/css/main.css"; -// import "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" -import "../../../../node_modules/@fortawesome/fontawesome-free/css/all.css"; -// import "../../node_modules/select2/dist/css/select2.min.css" -// import "../../node_modules/@toast-ui/editor/dist/toastui-editor.css" -// import "../../node_modules/codemirror/lib/codemirror.css" -// import "../../node_modules/@yaireo/tagify/dist/tagify.css" -import "../../../../node_modules/fomantic-ui/dist/semantic.min.css"; -import "../../../../node_modules/fomantic-ui/dist/components/accordion.min.css"; -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 "../electron/check.js"; -import { isStorybook, reloadPageToHome } from "../dependencies/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 }, - name: { type: String, reflect: true }, - logo: { type: String, reflect: true }, - activePage: { type: String, reflect: true }, - globalState: { type: Object, reflect: true }, - }; - } - - main; - sidebar; - subSidebar; - - // Custom Getter / Setter for Subtitle - #subtitle; - set subtitle(v) { - this.#subtitle = v; - this.sidebar.subtitle = v; - } - - get subtitle() { - return this.#subtitle; - } - - pagesById = {}; - page; - - next = () => this.main.next(); - back = () => this.main.back(); - - constructor(props = {}) { - super(); - - this.main = new Main(); - this.main.classList.add("dash-app"); - - this.sidebar = new Sidebar(); - this.sidebar.onClick = (_, value) => { - const id = value.info.id; - if (this.page) this.page.to(id); - else this.setAttribute("activePage", id); - }; - - this.subSidebar = new NavigationSidebar(); - this.subSidebar.onClick = async (id) => this.page.to(id); - - this.pages = props.pages ?? {}; - this.name = props.name; - this.logo = props.logo; - this.renderNameInSidebar = props.renderNameInSidebar ?? true; - - this.globalState = props.globalState; // Impose a static global state on pages that have none - - if (props.activePage) this.setAttribute("activePage", props.activePage); - - // Handle all pop and push state updates - const pushState = window.history.pushState; - - const pushPopListener = (popEvent) => { - if (popEvent.state) { - const titleString = popEvent.state.title ?? popEvent.state.label; - document.title = `${titleString} - ${this.name}`; - const page = this.pagesById[popEvent.state.page]; // ?? this.pagesById[this.#activatePage] - if (!page) return; - if (page === this.page) return; // Do not rerender current page - this.setMain(page); - } - }; - - window.history.pushState = function (state) { - pushPopListener({ state: state }); - return pushState.apply(window.history, arguments); - }; - - window.addEventListener("popstate", pushPopListener); - window.addEventListener("pushstate", pushPopListener); - - this.#updated(); - } - - requestPageUpdate() { - if (this.page) this.page.requestUpdate(); - } - - createRenderRoot() { - return this; - } - - attributeChangedCallback(key, _, latest) { - super.attributeChangedCallback(...arguments); - if (this.sidebar && (key === "name" || key === "logo")) - this.sidebar[key] = latest; - else if (key === "renderNameInSidebar") - this.sidebar.renderName = latest === "true" || latest === true; - else if (key === "pages") this.#updated(latest); - else if (key.toLowerCase() === "activepage") { - if (this.page && this.page.info.parent && this.page.info.section) { - const currentProject = getCurrentProjectName(); - if (currentProject) updateAppProgress(latest, currentProject); - } - - while (latest && !this.pagesById[latest]) - latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page - - // Update sidebar states - - this.sidebar.selectItem(latest); // Just highlight the item - this.sidebar.initialize = false; - this.#activatePage(latest); - return; - } else if (key.toLowerCase() === "globalstate" && this.page) { - this.page.info.globalState = JSON.parse(latest); - this.page.requestUpdate(); - } - } - - getPage(entry) { - if (!entry) return reloadPageToHome(); - const page = entry.page ?? entry; - if (page instanceof HTMLElement) return page; - else if (typeof page === "object") - return this.getPage(Object.values(page)[0]); - } - - updateSections( - { sidebar = true, main = false } = {}, - globalState = this.page.info.globalState, - ) { - const info = this.page.info; - let parent = info.parent; - - if (sidebar) { - this.subSidebar.sections = this.#getSections( - parent.info.pages, - globalState, - ); // Update sidebar items (if changed) - } - - const { sections } = this.subSidebar; - - if (main) { - if (this.page.header) delete this.page.header.sections; // Ensure sections are updated - this.main.set({ - page: this.page, - sections, - }); - } - - return sections; - } - - setMain(page) { - window.getSelection().empty(); // Remove user selection before transitioning - - // Update Previous Page - const info = page.info; - const previous = this.page; - - // if (previous === page) return // Prevent rerendering the same page - - const isNested = info.parent && info.section; - - const toPass = {}; - if (previous) { - previous.dismiss(); // Dismiss all notifications for this page - if (previous.info.globalState) - toPass.globalState = previous.info.globalState; // Pass global state over if appropriate - previous.active = false; - } - - // On initial reload, load global state if you can - if (isNested && !("globalState" in toPass)) - toPass.globalState = this.globalState ?? page.load(); - - // Update Active Page - this.page = page; - - // Reset global state if page has no parent - if (!this.page.info.parent) toPass.globalState = {}; - - if (isNested) { - let parent = info.parent; - while (parent.info.parent) parent = parent.info.parent; // Lock sections to the top-level parent - this.updateSections({ sidebar: true }, toPass.globalState); - this.subSidebar.active = info.id; // Update active item (if changed) - this.sidebar.hide(true); - this.subSidebar.show(); - } else { - this.sidebar.show(); - this.subSidebar.hide(); - } - - this.page.set(toPass, false); - - this.page.checkSyncState().then(async () => { - const projectName = info.globalState?.project?.name; - - this.subSidebar.header = projectName - ? `

${projectName}

Conversion Pipeline` - : projectName; - - this.updateSections({ sidebar: false, main: true }); - - if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready - - const { skipped } = - this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {}; - - if (skipped) { - if (isStorybook) return; // Do not skip on storybook - - // Run skip functions - Object.entries(page.workflow).forEach(([key, state]) => { - if (typeof state.skip === "function") state.skip(); - }); - - // Skip right over the page if configured as such - if (previous && previous.info.previous === this.page) - await this.page.onTransition(-1); - else await this.page.onTransition(1); - } - }); - } - - // Populate the sections tracked for this page by using the global state as a model - #getSections = (pages = {}, globalState = {}) => { - if (!globalState.sections) globalState.sections = {}; - - Object.entries(pages).forEach(([id, page]) => { - const info = page.info; - if (info.id) id = info.id; - - if (info.section) { - const section = info.section; - - let state = globalState.sections[section]; - if (!state) - state = globalState.sections[section] = { - open: false, - active: false, - pages: {}, - }; - - let pageState = state.pages[id]; - if (!pageState) - pageState = state.pages[id] = { - visited: false, - active: false, - saved: false, - pageLabel: page.info.label, - pageTitle: page.info.title, - }; - - info.states = pageState; - - state.active = false; - pageState.active = false; - - // Check if page is skipped based on workflow state (if applicable) - pageState.skipped = checkIfPageIsSkipped( - page, - globalState.project?.workflow, - ); - - if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states - - if (!("visited" in pageState)) pageState.visited = false; - if (id === this.page.info.id) - state.active = pageState.visited = pageState.active = true; // Set active page as visited - } - }); - - return (globalState.sections = { ...globalState.sections }); // Update global state with new reference (to ensure re-render) - }; - - #transitionPromise = {}; - - #updated(pages = this.pages) { - const url = new URL(window.location.href); - let active = url.pathname.slice(1); - if (isElectron || isStorybook) - active = new URLSearchParams(url.search).get("page"); - if (!active) active = this.activePage; // default to active page - - this.main.onTransition = async (transition) => { - const promise = (this.#transitionPromise.value = new Promise( - (resolve) => (this.#transitionPromise.trigger = resolve), - )); - - if (typeof transition === "number") { - const info = this.page.info; - const sign = Math.sign(transition); - if (sign === 1) transition = info.next.info.id; - else if (sign === -1) - transition = (info.previous ?? info.parent).info.id; // Default to back in time - } - - this.setAttribute("activePage", transition); - - return promise; - }; - - this.main.updatePages = () => { - this.#updated(); // Rerender with new pages - this.setAttribute("activePage", this.page.info.id); // Re-render the current page - }; - - this.pagesById = {}; - Object.entries(pages).forEach((arr) => this.addPage(this.pagesById, arr)); - this.sidebar.pages = pages; - - if (active) this.setAttribute("activePage", active); - } - - #activatePage = (id) => { - const page = this.getPage(this.pagesById[id]); - - if (page) { - const { id, label } = page.info; - const queries = new URLSearchParams(window.location.search); - queries.set("page", id); - const project = queries.get("project"); - const value = - isElectron || isStorybook - ? `?${queries}` - : `${window.location.origin}/${id === "/" ? "" : id}?${queries}`; - history.pushState({ page: id, label, project }, label, value); - } - }; - - // Track Pages By Id - addPage = (acc, arr) => { - let [id, page] = arr; - - const info = page.info; - - if (info.id) id = info.id; - else page.info.id = id; // update id - - const pages = info.pages; - - // NOTE: This is not true for nested pages with more info... - if (page instanceof HTMLElement) acc[id] = page; - - if (pages) { - const pagesArr = Object.values(pages); - - const originalNext = page.info.next; - page.info.next = pagesArr[0]; // Next is the first nested page - - // Update info with relative information - Object.entries(pages).forEach(([newId, nestedPage], i) => { - nestedPage.info.base = id; - - const previousPage = pagesArr[i - 1]; - nestedPage.info.previous = - (previousPage?.info?.pages - ? Object.values(previousPage.info.pages).pop() - : previousPage) ?? page; // Previous is the previous nested page or the parent page - nestedPage.info.next = pagesArr[i + 1] ?? originalNext; // Next is the next nested page or the original next page - nestedPage.info.id = `${id}/${newId}`; - nestedPage.info.parent = page; - }); - - // Register all pages - Object.entries(pages).forEach((arr) => this.addPage(acc, arr)); - } - - return acc; - }; - - #first = true; - updated() { - if (this.#first) { - this.#first = false; - this.#updated(); - } - } - - render() { - this.style.width = "100%"; - this.style.height = "100%"; - this.style.display = "grid"; - this.style.gridTemplateColumns = "fit-content(0px) 1fr"; - this.style.position = "relative"; - this.main.style.height = "100vh"; - - if (this.name) this.sidebar.name = this.name; - if (this.logo) this.sidebar.logo = this.logo; - if ("renderNameInSidebar" in this) - this.sidebar.renderName = this.renderNameInSidebar; - - return html` -
${this.sidebar} ${this.subSidebar}
- ${this.main} - `; - } -} - -customElements.get("nwb-dashboard") || - customElements.define("nwb-dashboard", Dashboard); +import { LitElement, html } from "lit"; +import useGlobalStyles from "./utils/useGlobalStyles.js"; + +import { Main, checkIfPageIsSkipped } from "./Main.js"; +import { Sidebar } from "./sidebar.js"; +import { NavigationSidebar } from "./NavigationSidebar.js"; + +// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) +import "../../assets/css/custom.css"; + +// Global styles to apply with the dashboard +import "../../assets/css/variables.css"; +import "../../assets/css/nativize.css"; +import "../../assets/css/global.css"; +import "../../assets/css/nav.css"; +import "../../assets/css/section.css"; +import "../../assets/css/demo.css"; +import "../../assets/css/individualtab.css"; +import "../../assets/css/main_tabs.css"; +// import "../../node_modules/cropperjs/dist/cropper.css" +import "../../../../node_modules/notyf/notyf.min.css"; +import "../../assets/css/spur.css"; +import "../../assets/css/main.css"; +// import "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" +import "../../../../node_modules/@fortawesome/fontawesome-free/css/all.css"; +// import "../../node_modules/select2/dist/css/select2.min.css" +// import "../../node_modules/@toast-ui/editor/dist/toastui-editor.css" +// import "../../node_modules/codemirror/lib/codemirror.css" +// import "../../node_modules/@yaireo/tagify/dist/tagify.css" +import "../../../../node_modules/fomantic-ui/dist/semantic.min.css"; +import "../../../../node_modules/fomantic-ui/dist/components/accordion.min.css"; +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 "../electron/check.js"; +import { isStorybook, reloadPageToHome } from "../dependencies/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 }, + name: { type: String, reflect: true }, + logo: { type: String, reflect: true }, + activePage: { type: String, reflect: true }, + globalState: { type: Object, reflect: true }, + }; + } + + main; + sidebar; + subSidebar; + + // Custom Getter / Setter for Subtitle + #subtitle; + set subtitle(v) { + this.#subtitle = v; + this.sidebar.subtitle = v; + } + + get subtitle() { + return this.#subtitle; + } + + pagesById = {}; + page; + + next = () => this.main.next(); + back = () => this.main.back(); + + constructor(props = {}) { + super(); + + this.main = new Main(); + this.main.classList.add("dash-app"); + + this.sidebar = new Sidebar(); + this.sidebar.onClick = (_, value) => { + const id = value.info.id; + if (this.page) this.page.to(id); + else this.setAttribute("activePage", id); + }; + + this.subSidebar = new NavigationSidebar(); + this.subSidebar.onClick = async (id) => this.page.to(id); + + this.pages = props.pages ?? {}; + this.name = props.name; + this.logo = props.logo; + this.renderNameInSidebar = props.renderNameInSidebar ?? true; + + this.globalState = props.globalState; // Impose a static global state on pages that have none + + if (props.activePage) this.setAttribute("activePage", props.activePage); + + // Handle all pop and push state updates + const pushState = window.history.pushState; + + const pushPopListener = (popEvent) => { + if (popEvent.state) { + const titleString = popEvent.state.title ?? popEvent.state.label; + document.title = `${titleString} - ${this.name}`; + const page = this.pagesById[popEvent.state.page]; // ?? this.pagesById[this.#activatePage] + if (!page) return; + if (page === this.page) return; // Do not rerender current page + this.setMain(page); + } + }; + + window.history.pushState = function (state) { + pushPopListener({ state: state }); + return pushState.apply(window.history, arguments); + }; + + window.addEventListener("popstate", pushPopListener); + window.addEventListener("pushstate", pushPopListener); + + this.#updated(); + } + + requestPageUpdate() { + if (this.page) this.page.requestUpdate(); + } + + createRenderRoot() { + return this; + } + + attributeChangedCallback(key, _, latest) { + super.attributeChangedCallback(...arguments); + if (this.sidebar && (key === "name" || key === "logo")) this.sidebar[key] = latest; + else if (key === "renderNameInSidebar") this.sidebar.renderName = latest === "true" || latest === true; + else if (key === "pages") this.#updated(latest); + else if (key.toLowerCase() === "activepage") { + if (this.page && this.page.info.parent && this.page.info.section) { + const currentProject = getCurrentProjectName(); + if (currentProject) updateAppProgress(latest, currentProject); + } + + while (latest && !this.pagesById[latest]) latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page + + // Update sidebar states + + this.sidebar.selectItem(latest); // Just highlight the item + this.sidebar.initialize = false; + this.#activatePage(latest); + return; + } else if (key.toLowerCase() === "globalstate" && this.page) { + this.page.info.globalState = JSON.parse(latest); + this.page.requestUpdate(); + } + } + + getPage(entry) { + if (!entry) return reloadPageToHome(); + const page = entry.page ?? entry; + if (page instanceof HTMLElement) return page; + else if (typeof page === "object") return this.getPage(Object.values(page)[0]); + } + + updateSections({ sidebar = true, main = false } = {}, globalState = this.page.info.globalState) { + const info = this.page.info; + let parent = info.parent; + + if (sidebar) { + this.subSidebar.sections = this.#getSections(parent.info.pages, globalState); // Update sidebar items (if changed) + } + + const { sections } = this.subSidebar; + + if (main) { + if (this.page.header) delete this.page.header.sections; // Ensure sections are updated + this.main.set({ + page: this.page, + sections, + }); + } + + return sections; + } + + setMain(page) { + window.getSelection().empty(); // Remove user selection before transitioning + + // Update Previous Page + const info = page.info; + const previous = this.page; + + // if (previous === page) return // Prevent rerendering the same page + + const isNested = info.parent && info.section; + + const toPass = {}; + if (previous) { + previous.dismiss(); // Dismiss all notifications for this page + if (previous.info.globalState) toPass.globalState = previous.info.globalState; // Pass global state over if appropriate + previous.active = false; + } + + // On initial reload, load global state if you can + if (isNested && !("globalState" in toPass)) toPass.globalState = this.globalState ?? page.load(); + + // Update Active Page + this.page = page; + + // Reset global state if page has no parent + if (!this.page.info.parent) toPass.globalState = {}; + + if (isNested) { + let parent = info.parent; + while (parent.info.parent) parent = parent.info.parent; // Lock sections to the top-level parent + this.updateSections({ sidebar: true }, toPass.globalState); + this.subSidebar.active = info.id; // Update active item (if changed) + this.sidebar.hide(true); + this.subSidebar.show(); + } else { + this.sidebar.show(); + this.subSidebar.hide(); + } + + this.page.set(toPass, false); + + this.page.checkSyncState().then(async () => { + const projectName = info.globalState?.project?.name; + + this.subSidebar.header = projectName + ? `

${projectName}

Conversion Pipeline` + : projectName; + + this.updateSections({ sidebar: false, main: true }); + + if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready + + const { skipped } = this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {}; + + if (skipped) { + if (isStorybook) return; // Do not skip on storybook + + // Run skip functions + Object.entries(page.workflow).forEach(([key, state]) => { + if (typeof state.skip === "function") state.skip(); + }); + + // Skip right over the page if configured as such + if (previous && previous.info.previous === this.page) await this.page.onTransition(-1); + else await this.page.onTransition(1); + } + }); + } + + // Populate the sections tracked for this page by using the global state as a model + #getSections = (pages = {}, globalState = {}) => { + if (!globalState.sections) globalState.sections = {}; + + Object.entries(pages).forEach(([id, page]) => { + const info = page.info; + if (info.id) id = info.id; + + if (info.section) { + const section = info.section; + + let state = globalState.sections[section]; + if (!state) + state = globalState.sections[section] = { + open: false, + active: false, + pages: {}, + }; + + let pageState = state.pages[id]; + if (!pageState) + pageState = state.pages[id] = { + visited: false, + active: false, + saved: false, + pageLabel: page.info.label, + pageTitle: page.info.title, + }; + + info.states = pageState; + + state.active = false; + pageState.active = false; + + // Check if page is skipped based on workflow state (if applicable) + pageState.skipped = checkIfPageIsSkipped(page, globalState.project?.workflow); + + if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states + + if (!("visited" in pageState)) pageState.visited = false; + if (id === this.page.info.id) state.active = pageState.visited = pageState.active = true; // Set active page as visited + } + }); + + return (globalState.sections = { ...globalState.sections }); // Update global state with new reference (to ensure re-render) + }; + + #transitionPromise = {}; + + #updated(pages = this.pages) { + const url = new URL(window.location.href); + let active = url.pathname.slice(1); + if (isElectron || isStorybook) active = new URLSearchParams(url.search).get("page"); + if (!active) active = this.activePage; // default to active page + + this.main.onTransition = async (transition) => { + const promise = (this.#transitionPromise.value = new Promise( + (resolve) => (this.#transitionPromise.trigger = resolve) + )); + + if (typeof transition === "number") { + const info = this.page.info; + const sign = Math.sign(transition); + if (sign === 1) transition = info.next.info.id; + else if (sign === -1) transition = (info.previous ?? info.parent).info.id; // Default to back in time + } + + this.setAttribute("activePage", transition); + + return promise; + }; + + this.main.updatePages = () => { + this.#updated(); // Rerender with new pages + this.setAttribute("activePage", this.page.info.id); // Re-render the current page + }; + + this.pagesById = {}; + Object.entries(pages).forEach((arr) => this.addPage(this.pagesById, arr)); + this.sidebar.pages = pages; + + if (active) this.setAttribute("activePage", active); + } + + #activatePage = (id) => { + const page = this.getPage(this.pagesById[id]); + + if (page) { + const { id, label } = page.info; + const queries = new URLSearchParams(window.location.search); + queries.set("page", id); + const project = queries.get("project"); + const value = + isElectron || isStorybook + ? `?${queries}` + : `${window.location.origin}/${id === "/" ? "" : id}?${queries}`; + history.pushState({ page: id, label, project }, label, value); + } + }; + + // Track Pages By Id + addPage = (acc, arr) => { + let [id, page] = arr; + + const info = page.info; + + if (info.id) id = info.id; + else page.info.id = id; // update id + + const pages = info.pages; + + // NOTE: This is not true for nested pages with more info... + if (page instanceof HTMLElement) acc[id] = page; + + if (pages) { + const pagesArr = Object.values(pages); + + const originalNext = page.info.next; + page.info.next = pagesArr[0]; // Next is the first nested page + + // Update info with relative information + Object.entries(pages).forEach(([newId, nestedPage], i) => { + nestedPage.info.base = id; + + const previousPage = pagesArr[i - 1]; + nestedPage.info.previous = + (previousPage?.info?.pages ? Object.values(previousPage.info.pages).pop() : previousPage) ?? page; // Previous is the previous nested page or the parent page + nestedPage.info.next = pagesArr[i + 1] ?? originalNext; // Next is the next nested page or the original next page + nestedPage.info.id = `${id}/${newId}`; + nestedPage.info.parent = page; + }); + + // Register all pages + Object.entries(pages).forEach((arr) => this.addPage(acc, arr)); + } + + return acc; + }; + + #first = true; + updated() { + if (this.#first) { + this.#first = false; + this.#updated(); + } + } + + render() { + this.style.width = "100%"; + this.style.height = "100%"; + this.style.display = "grid"; + this.style.gridTemplateColumns = "fit-content(0px) 1fr"; + this.style.position = "relative"; + this.main.style.height = "100vh"; + + if (this.name) this.sidebar.name = this.name; + if (this.logo) this.sidebar.logo = this.logo; + if ("renderNameInSidebar" in this) this.sidebar.renderName = this.renderNameInSidebar; + + return html` +
${this.sidebar} ${this.subSidebar}
+ ${this.main} + `; + } +} + +customElements.get("nwb-dashboard") || customElements.define("nwb-dashboard", Dashboard); diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 88955db8cc..ed59dfdcbb 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,485 +1,445 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils.js"; - -import { - homeDirectory, - notyf, - testDataFolderPath, -} from "../../../dependencies.js"; -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, - onUpdateAvailable, - onUpdateProgress, - registerUpdateProgress, -} from "../../../../../utils/electron.js"; - -import saveSVG from "../../assets/save.svg?raw"; -import folderSVG from "../../assets/folder_open.svg?raw"; -import deleteSVG from "../../assets/delete.svg?raw"; -import generateSVG from "../../assets/restart.svg?raw"; -import downloadSVG from "../../assets/download.svg?raw"; -import infoSVG from "../../assets/info.svg?raw"; - -import { header } from "../../forms/utils"; - -import testingSuiteYaml from "../../../../../../guide_testing_suite.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals.js"; -import { Modal } from "../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath( - testDataFolderPath, - "multi_session_dataset", -); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => - fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone( - info.interfaces ? info.metadata ?? {} : {}, - ); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - }, -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) - return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify( - `Test dataset successfully generated at ${sanitizedOutputPath}!`, - ); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) - return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) - return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error", - ); - - const { pipelines = {} } = testingSuiteYaml; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce( - (acc, v) => (acc += v === true ? 1 : 0), - 0, - ); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning", - ); - } else if (nSuccessful) - this.#openNotyf( - `Generated ${nSuccessful} test pipelines.`, - "success", - ); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error", - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement([ - "developer", - "testing_data_folder", - ]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && - fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify( - `Test dataset successfully deleted from your system.`, - ); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send( - "showItemInFolder", - DATASET_OUTPUT_PATH, - ); - else { - this.notify( - "The test dataset no longer exists!", - "warning", - ); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send( - "showItemInFolder", - output_path, - ); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || - customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils.js"; + +import { homeDirectory, notyf, testDataFolderPath } from "../../../dependencies.js"; +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, + onUpdateAvailable, + onUpdateProgress, + registerUpdateProgress, +} from "../../../../../utils/electron.js"; + +import saveSVG from "../../assets/save.svg?raw"; +import folderSVG from "../../assets/folder_open.svg?raw"; +import deleteSVG from "../../assets/delete.svg?raw"; +import generateSVG from "../../assets/restart.svg?raw"; +import downloadSVG from "../../assets/download.svg?raw"; +import infoSVG from "../../assets/info.svg?raw"; + +import { header } from "../../forms/utils"; + +import testingSuiteYaml from "../../../../../../guide_testing_suite.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals.js"; +import { Modal } from "../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + } +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error" + ); + + const { pipelines = {} } = testingSuiteYaml; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning" + ); + } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error" + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify(`Test dataset successfully deleted from your system.`); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); + else { + this.notify("The test dataset no longer exists!", "warning"); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send("showItemInFolder", output_path); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); diff --git a/src/electron/frontend/core/components/sidebar.js b/src/electron/frontend/core/components/sidebar.js index bbf9ace683..6d79ed6e0a 100644 --- a/src/electron/frontend/core/components/sidebar.js +++ b/src/electron/frontend/core/components/sidebar.js @@ -1,261 +1,253 @@ -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 - -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 }, - name: { type: String, reflect: true }, - logo: { type: String, reflect: true }, - - renderName: { type: Boolean, reflect: true }, - }; - } - - // Custom Getter / Setter for Subtitle - #subtitle; - set subtitle(v) { - this.#subtitle = v; - this.requestUpdate(); - } - - get subtitle() { - return this.#subtitle; - } - - initialize = true; - - constructor(props = {}) { - super(); - this.pages = props.pages ?? {}; - this.name = props.name ?? ""; - this.logo = props.logo; - this.subtitle = props.subtitle ?? "0.0.1"; - this.renderName = props.renderName ?? true; - } - - // This method turns off shadow DOM to allow for global styles (e.g. bootstrap) - // NOTE: This component checks whether this is active to determine how to handle styles and internal element references - createRenderRoot() { - return this; - } - - attributeChangedCallback(...args) { - const attrs = ["pages", "name", "subtitle", "renderName"]; - super.attributeChangedCallback(...args); - if (attrs.includes(args[0])) this.requestUpdate(); - } - - updated() { - this.nav = (this.shadowRoot ?? this).querySelector("#main-nav"); - - this.subtitleElement = (this.shadowRoot ?? this).querySelector("#subtitle"); - - // Toggle sidebar - const toggle = (this.toggle = (this.shadowRoot ?? this).querySelector( - "#sidebarCollapse", - )); - toggle.onclick = () => { - this.nav.classList.toggle("active"); - toggle.classList.toggle("active"); - }; - - // Actually click the item - let selectedItem = this.#selected - ? (this.shadowRoot ?? this).querySelector( - `ul[data-id='${this.#selected}']`, - ) - : (this.shadowRoot ?? this).querySelector("ul").querySelector("a"); - if (this.initialize && selectedItem) selectedItem.click(); - else if (this.#selected) this.selectItem(this.#selected); // Visually select the item - - if (this.#hidden) this.hide(true); - } - - show = () => { - this.#hidden = false; - - if (this.nav) { - this.nav.classList.remove("active"); - this.toggle.classList.remove("active"); - this.style.display = ""; - } - }; - - #hidden = false; - - hide = (changeDisplay) => { - this.#hidden = true; - if (this.nav) { - this.nav.classList.add("active"); - this.toggle.classList.add("active"); - if (changeDisplay) this.style.display = "none"; - } - }; - - onClick = () => {}; // Set by the user - - selectItem = (id) => { - this.#selected = id.split("/")[0] || "/"; - const links = (this.shadowRoot ?? this).querySelectorAll("a"); - links.forEach((a) => a.classList.remove("is-selected")); - const a = (this.shadowRoot ?? this).querySelector( - `a[data-id="${this.#selected}"]`, - ); - if (a) a.classList.add("is-selected"); - }; - - #onClick = (id) => { - if (!this.pages[id]) throw new Error(`No page found for key ${id}`); - this.selectItem(id); - this.onClick(id, this.pages[id]); - }; - - #selected = ""; - - select = (id) => { - const info = this.pages?.[id]; - if (info) this.#onClick(id, info); - }; - - render() { - const hasName = this.name && this.renderName; - const logoNoName = this.logo && !hasName; - - return html` - -
- - `; - } -} - -customElements.get("nwb-sidebar") || - customElements.define("nwb-sidebar", Sidebar); +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 + +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 }, + name: { type: String, reflect: true }, + logo: { type: String, reflect: true }, + + renderName: { type: Boolean, reflect: true }, + }; + } + + // Custom Getter / Setter for Subtitle + #subtitle; + set subtitle(v) { + this.#subtitle = v; + this.requestUpdate(); + } + + get subtitle() { + return this.#subtitle; + } + + initialize = true; + + constructor(props = {}) { + super(); + this.pages = props.pages ?? {}; + this.name = props.name ?? ""; + this.logo = props.logo; + this.subtitle = props.subtitle ?? "0.0.1"; + this.renderName = props.renderName ?? true; + } + + // This method turns off shadow DOM to allow for global styles (e.g. bootstrap) + // NOTE: This component checks whether this is active to determine how to handle styles and internal element references + createRenderRoot() { + return this; + } + + attributeChangedCallback(...args) { + const attrs = ["pages", "name", "subtitle", "renderName"]; + super.attributeChangedCallback(...args); + if (attrs.includes(args[0])) this.requestUpdate(); + } + + updated() { + this.nav = (this.shadowRoot ?? this).querySelector("#main-nav"); + + this.subtitleElement = (this.shadowRoot ?? this).querySelector("#subtitle"); + + // Toggle sidebar + const toggle = (this.toggle = (this.shadowRoot ?? this).querySelector("#sidebarCollapse")); + toggle.onclick = () => { + this.nav.classList.toggle("active"); + toggle.classList.toggle("active"); + }; + + // Actually click the item + let selectedItem = this.#selected + ? (this.shadowRoot ?? this).querySelector(`ul[data-id='${this.#selected}']`) + : (this.shadowRoot ?? this).querySelector("ul").querySelector("a"); + if (this.initialize && selectedItem) selectedItem.click(); + else if (this.#selected) this.selectItem(this.#selected); // Visually select the item + + if (this.#hidden) this.hide(true); + } + + show = () => { + this.#hidden = false; + + if (this.nav) { + this.nav.classList.remove("active"); + this.toggle.classList.remove("active"); + this.style.display = ""; + } + }; + + #hidden = false; + + hide = (changeDisplay) => { + this.#hidden = true; + if (this.nav) { + this.nav.classList.add("active"); + this.toggle.classList.add("active"); + if (changeDisplay) this.style.display = "none"; + } + }; + + onClick = () => {}; // Set by the user + + selectItem = (id) => { + this.#selected = id.split("/")[0] || "/"; + const links = (this.shadowRoot ?? this).querySelectorAll("a"); + links.forEach((a) => a.classList.remove("is-selected")); + const a = (this.shadowRoot ?? this).querySelector(`a[data-id="${this.#selected}"]`); + if (a) a.classList.add("is-selected"); + }; + + #onClick = (id) => { + if (!this.pages[id]) throw new Error(`No page found for key ${id}`); + this.selectItem(id); + this.onClick(id, this.pages[id]); + }; + + #selected = ""; + + select = (id) => { + const info = this.pages?.[id]; + if (info) this.#onClick(id, info); + }; + + render() { + const hasName = this.name && this.renderName; + const logoNoName = this.logo && !hasName; + + return html` + + + `; + } +} + +customElements.get("nwb-sidebar") || customElements.define("nwb-sidebar", Sidebar); diff --git a/src/electron/frontend/core/pages.js b/src/electron/frontend/core/pages.js index f44c14ed05..12bfafee97 100644 --- a/src/electron/frontend/core/pages.js +++ b/src/electron/frontend/core/pages.js @@ -1,195 +1,195 @@ -import { GettingStartedPage } from "./stories/pages/getting-started/GettingStarted"; -import { DocumentationPage } from "./stories/pages/documentation/Documentation"; -import { ContactPage } from "./stories/pages/contact-us/Contact"; -import { GuidedHomePage } from "./stories/pages/guided-mode/GuidedHome"; -import { GuidedNewDatasetPage } from "./stories/pages/guided-mode/setup/GuidedNewDatasetInfo"; -import { GuidedStructurePage } from "./stories/pages/guided-mode/data/GuidedStructure"; -import { sections } from "./stories/pages/globals"; -import { GuidedSubjectsPage } from "./stories/pages/guided-mode/setup/GuidedSubjects"; -import { GuidedSourceDataPage } from "./stories/pages/guided-mode/data/GuidedSourceData"; -import { GuidedMetadataPage } from "./stories/pages/guided-mode/data/GuidedMetadata"; -import { GuidedUploadPage } from "./stories/pages/guided-mode/options/GuidedUpload"; -import { GuidedResultsPage } from "./stories/pages/guided-mode/results/GuidedResults"; -import { Dashboard } from "./stories/Dashboard"; -import { GuidedStubPreviewPage } from "./stories/pages/guided-mode/options/GuidedStubPreview"; -import { GuidedInspectorPage } from "./stories/pages/guided-mode/options/GuidedInspectorPage"; - -import logo from "../assets/img/logo-guide-draft-transparent-tight.png"; -import { GuidedPathExpansionPage } from "./stories/pages/guided-mode/data/GuidedPathExpansion"; -import uploadIcon from "./stories/assets/dandi.svg?raw"; -import inspectIcon from "./stories/assets/inspect.svg?raw"; -import neurosiftIcon from "./stories/assets/neurosift-logo.svg?raw"; - -import settingsIcon from "./stories/assets/settings.svg?raw"; - -import { UploadsPage } from "./stories/pages/uploads/UploadsPage"; -import { SettingsPage } from "./stories/pages/settings/SettingsPage"; -import { InspectPage } from "./stories/pages/inspect/InspectPage"; -import { PreviewPage } from "./stories/pages/preview/PreviewPage"; -import { GuidedPreform } from "./stories/pages/guided-mode/setup/Preform"; -import { GuidedDandiResultsPage } from "./stories/pages/guided-mode/results/GuidedDandiResults"; - -let dashboard = document.querySelector("nwb-dashboard"); -if (!dashboard) dashboard = new Dashboard(); -dashboard.logo = logo; -dashboard.name = "NWB GUIDE"; -dashboard.renderNameInSidebar = false; - -const resourcesGroup = "Resources"; - -const guidedIcon = ` - - - - -`; - -const documentationIcon = ` - - -`; - -const contactIcon = ` - -`; - -const pages = { - "/": new GuidedHomePage({ - label: "Convert", - icon: guidedIcon, - pages: { - details: new GuidedNewDatasetPage({ - title: "Project Setup", - label: "Project details", - section: sections[0], - }), - - workflow: new GuidedPreform({ - title: "Pipeline Workflow", - label: "Pipeline workflow", - section: sections[0], - }), - - structure: new GuidedStructurePage({ - title: "Provide Data Formats", - label: "Data formats", - section: sections[0], - }), - - locate: new GuidedPathExpansionPage({ - title: "Locate Data", - label: "Locate data", - section: sections[0], - }), - - subjects: new GuidedSubjectsPage({ - title: "Subject Metadata", - label: "Subject details", - section: sections[0], - }), - - sourcedata: new GuidedSourceDataPage({ - title: "Source Data Information", - label: "Source data", - section: sections[1], - }), - - metadata: new GuidedMetadataPage({ - title: "File Metadata", - label: "File metadata", - section: sections[1], - }), - - inspect: new GuidedInspectorPage({ - title: "Inspector Report", - label: "Validate metadata", - section: sections[2], - sync: ["preview"], - }), - - preview: new GuidedStubPreviewPage({ - title: "Conversion Preview", - label: "Preview NWB files", - section: sections[2], - sync: ["preview"], - }), - - conversion: new GuidedResultsPage({ - title: "Conversion Review", - label: "Review conversion", - section: sections[2], - sync: ["conversion"], - }), - - upload: new GuidedUploadPage({ - title: "DANDI Upload", - label: "Upload to DANDI", - section: sections[3], - sync: ["conversion"], - }), - - review: new GuidedDandiResultsPage({ - title: "Upload Review", - label: "Review published data", - section: sections[3], - }), - }, - }), - validate: new InspectPage({ - label: "Validate", - icon: inspectIcon, - }), - explore: new PreviewPage({ - label: "Explore", - icon: neurosiftIcon, - }), - uploads: new UploadsPage({ - label: "Upload", - icon: uploadIcon, - }), - docs: new DocumentationPage({ - label: "Documentation", - icon: documentationIcon, - group: resourcesGroup, - }), - contact: new ContactPage({ - label: "Contact Us", - icon: contactIcon, - group: resourcesGroup, - }), - settings: new SettingsPage({ - label: "Settings", - icon: settingsIcon, - group: "bottom", - }), -}; - -dashboard.pages = pages; - -export { dashboard }; +import { GettingStartedPage } from "./stories/pages/getting-started/GettingStarted"; +import { DocumentationPage } from "./stories/pages/documentation/Documentation"; +import { ContactPage } from "./stories/pages/contact-us/Contact"; +import { GuidedHomePage } from "./stories/pages/guided-mode/GuidedHome"; +import { GuidedNewDatasetPage } from "./stories/pages/guided-mode/setup/GuidedNewDatasetInfo"; +import { GuidedStructurePage } from "./stories/pages/guided-mode/data/GuidedStructure"; +import { sections } from "./stories/pages/globals"; +import { GuidedSubjectsPage } from "./stories/pages/guided-mode/setup/GuidedSubjects"; +import { GuidedSourceDataPage } from "./stories/pages/guided-mode/data/GuidedSourceData"; +import { GuidedMetadataPage } from "./stories/pages/guided-mode/data/GuidedMetadata"; +import { GuidedUploadPage } from "./stories/pages/guided-mode/options/GuidedUpload"; +import { GuidedResultsPage } from "./stories/pages/guided-mode/results/GuidedResults"; +import { Dashboard } from "./stories/Dashboard"; +import { GuidedStubPreviewPage } from "./stories/pages/guided-mode/options/GuidedStubPreview"; +import { GuidedInspectorPage } from "./stories/pages/guided-mode/options/GuidedInspectorPage"; + +import logo from "../assets/img/logo-guide-draft-transparent-tight.png"; +import { GuidedPathExpansionPage } from "./stories/pages/guided-mode/data/GuidedPathExpansion"; +import uploadIcon from "./stories/assets/dandi.svg?raw"; +import inspectIcon from "./stories/assets/inspect.svg?raw"; +import neurosiftIcon from "./stories/assets/neurosift-logo.svg?raw"; + +import settingsIcon from "./stories/assets/settings.svg?raw"; + +import { UploadsPage } from "./stories/pages/uploads/UploadsPage"; +import { SettingsPage } from "./stories/pages/settings/SettingsPage"; +import { InspectPage } from "./stories/pages/inspect/InspectPage"; +import { PreviewPage } from "./stories/pages/preview/PreviewPage"; +import { GuidedPreform } from "./stories/pages/guided-mode/setup/Preform"; +import { GuidedDandiResultsPage } from "./stories/pages/guided-mode/results/GuidedDandiResults"; + +let dashboard = document.querySelector("nwb-dashboard"); +if (!dashboard) dashboard = new Dashboard(); +dashboard.logo = logo; +dashboard.name = "NWB GUIDE"; +dashboard.renderNameInSidebar = false; + +const resourcesGroup = "Resources"; + +const guidedIcon = ` + + + + +`; + +const documentationIcon = ` + + +`; + +const contactIcon = ` + +`; + +const pages = { + "/": new GuidedHomePage({ + label: "Convert", + icon: guidedIcon, + pages: { + details: new GuidedNewDatasetPage({ + title: "Project Setup", + label: "Project details", + section: sections[0], + }), + + workflow: new GuidedPreform({ + title: "Pipeline Workflow", + label: "Pipeline workflow", + section: sections[0], + }), + + structure: new GuidedStructurePage({ + title: "Provide Data Formats", + label: "Data formats", + section: sections[0], + }), + + locate: new GuidedPathExpansionPage({ + title: "Locate Data", + label: "Locate data", + section: sections[0], + }), + + subjects: new GuidedSubjectsPage({ + title: "Subject Metadata", + label: "Subject details", + section: sections[0], + }), + + sourcedata: new GuidedSourceDataPage({ + title: "Source Data Information", + label: "Source data", + section: sections[1], + }), + + metadata: new GuidedMetadataPage({ + title: "File Metadata", + label: "File metadata", + section: sections[1], + }), + + inspect: new GuidedInspectorPage({ + title: "Inspector Report", + label: "Validate metadata", + section: sections[2], + sync: ["preview"], + }), + + preview: new GuidedStubPreviewPage({ + title: "Conversion Preview", + label: "Preview NWB files", + section: sections[2], + sync: ["preview"], + }), + + conversion: new GuidedResultsPage({ + title: "Conversion Review", + label: "Review conversion", + section: sections[2], + sync: ["conversion"], + }), + + upload: new GuidedUploadPage({ + title: "DANDI Upload", + label: "Upload to DANDI", + section: sections[3], + sync: ["conversion"], + }), + + review: new GuidedDandiResultsPage({ + title: "Upload Review", + label: "Review published data", + section: sections[3], + }), + }, + }), + validate: new InspectPage({ + label: "Validate", + icon: inspectIcon, + }), + explore: new PreviewPage({ + label: "Explore", + icon: neurosiftIcon, + }), + uploads: new UploadsPage({ + label: "Upload", + icon: uploadIcon, + }), + docs: new DocumentationPage({ + label: "Documentation", + icon: documentationIcon, + group: resourcesGroup, + }), + contact: new ContactPage({ + label: "Contact Us", + icon: contactIcon, + group: resourcesGroup, + }), + settings: new SettingsPage({ + label: "Settings", + icon: settingsIcon, + group: "bottom", + }), +}; + +dashboard.pages = pages; + +export { dashboard }; diff --git a/src/electron/frontend/utils/electron.js b/src/electron/frontend/utils/electron.js index 510635ce78..f9b73df5f1 100644 --- a/src/electron/frontend/utils/electron.js +++ b/src/electron/frontend/utils/electron.js @@ -1,98 +1,86 @@ -import { updateURLParams } from "../../utils/url.js"; -import isElectron from "./check.js"; - -export { isElectron }; - -export let port = 4242; -export let SERVER_FILE_PATH = ""; -export const electron = globalThis.electron ?? {}; // ipcRenderer, remote, shell, etc. -export let fs = null; -export let os = null; -export let remote = {}; -export let app = null; -export let path = null; -export let log = null; -export let crypto = null; - -let updateAvailable = false; -const updateAvailableCallbacks = []; -export const onUpdateAvailable = (callback) => { - if (updateAvailable) callback(updateAvailable); - else updateAvailableCallbacks.push(callback); -}; - -let updateProgress = null; - -const updateProgressCallbacks = []; -export const onUpdateProgress = (callback) => { - if (updateProgress) callback(updateProgress); - else updateProgressCallbacks.push(callback); -}; - -export const registerUpdateProgress = (info) => { - updateProgress = info; - updateProgressCallbacks.forEach((cb) => cb(info)); -}; - -const registerUpdate = (info) => { - updateAvailable = info; - document.body.setAttribute("data-update-available", JSON.stringify(info)); - updateAvailableCallbacks.forEach((cb) => cb(info)); -}; - -// Used in tests -try { - crypto = require("crypto"); -} catch {} - -if (isElectron) { - try { - fs = require("fs-extra"); // File System - os = require("os"); - crypto = require("crypto"); - remote = require("@electron/remote"); - app = remote.app; - - electron.ipcRenderer.on("fileOpened", (info, filepath) => { - updateURLParams({ file: filepath }); - const dashboard = document.querySelector("nwb-dashboard"); - const activePage = dashboard.getAttribute("activePage"); - if (activePage === "preview") dashboard.requestUpdate(); - else dashboard.setAttribute("activePage", "preview"); - }); - - ["log", "warn", "error"].forEach((method) => - electron.ipcRenderer.on(`console.${method}`, (_, ...args) => - console[method](`[main-process]:`, ...args), - ), - ); - - electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => - console.log(`[Update]:`, ...args), - ); - - electron.ipcRenderer.on(`update-available`, (_, info) => - info ? registerUpdate(info) : "", - ); - - electron.ipcRenderer.on(`update-progress`, (_, info) => - registerUpdateProgress(info), - ); - electron.ipcRenderer.on(`update-complete`, (_, ...args) => - console.log(`[Update]:`, ...args), - ); - - electron.ipcRenderer.on(`update-error`, (_, ...args) => - console.log(`[Update]:`, ...args), - ); - - port = electron.ipcRenderer.sendSync("get-port"); - console.log("User OS:", os.type(), os.platform(), "version:", os.release()); - - SERVER_FILE_PATH = electron.ipcRenderer.sendSync("get-server-file-path"); - - path = require("path"); - } catch (error) { - console.error("Electron API access failed —", error); - } -} else console.warn("Electron API is blocked for web builds"); +import { updateURLParams } from "../../utils/url.js"; +import isElectron from "./check.js"; + +export { isElectron }; + +export let port = 4242; +export let SERVER_FILE_PATH = ""; +export const electron = globalThis.electron ?? {}; // ipcRenderer, remote, shell, etc. +export let fs = null; +export let os = null; +export let remote = {}; +export let app = null; +export let path = null; +export let log = null; +export let crypto = null; + +let updateAvailable = false; +const updateAvailableCallbacks = []; +export const onUpdateAvailable = (callback) => { + if (updateAvailable) callback(updateAvailable); + else updateAvailableCallbacks.push(callback); +}; + +let updateProgress = null; + +const updateProgressCallbacks = []; +export const onUpdateProgress = (callback) => { + if (updateProgress) callback(updateProgress); + else updateProgressCallbacks.push(callback); +}; + +export const registerUpdateProgress = (info) => { + updateProgress = info; + updateProgressCallbacks.forEach((cb) => cb(info)); +}; + +const registerUpdate = (info) => { + updateAvailable = info; + document.body.setAttribute("data-update-available", JSON.stringify(info)); + updateAvailableCallbacks.forEach((cb) => cb(info)); +}; + +// Used in tests +try { + crypto = require("crypto"); +} catch {} + +if (isElectron) { + try { + fs = require("fs-extra"); // File System + os = require("os"); + crypto = require("crypto"); + remote = require("@electron/remote"); + app = remote.app; + + electron.ipcRenderer.on("fileOpened", (info, filepath) => { + updateURLParams({ file: filepath }); + const dashboard = document.querySelector("nwb-dashboard"); + const activePage = dashboard.getAttribute("activePage"); + if (activePage === "preview") dashboard.requestUpdate(); + else dashboard.setAttribute("activePage", "preview"); + }); + + ["log", "warn", "error"].forEach((method) => + electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) + ); + + electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)); + + electron.ipcRenderer.on(`update-available`, (_, info) => (info ? registerUpdate(info) : "")); + + electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)); + electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)); + + electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)); + + port = electron.ipcRenderer.sendSync("get-port"); + console.log("User OS:", os.type(), os.platform(), "version:", os.release()); + + SERVER_FILE_PATH = electron.ipcRenderer.sendSync("get-server-file-path"); + + path = require("path"); + } catch (error) { + console.error("Electron API access failed —", error); + } +} else console.warn("Electron API is blocked for web builds"); From 230e49119f8de0d813a0c46df7c2b10485bfae83 Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Wed, 29 May 2024 14:44:57 -0400 Subject: [PATCH 06/18] fix improper conflicts --- .../frontend/core/components/Dashboard.js | 908 ++++++++--------- .../components/pages/settings/SettingsPage.js | 925 +++++++++--------- src/electron/frontend/core/pages.js | 389 ++++---- src/electron/frontend/utils/electron.js | 184 ++-- 4 files changed, 1238 insertions(+), 1168 deletions(-) diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index de5aeb61d7..c28ae1bf6a 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -1,442 +1,466 @@ -import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; - -import { Main, checkIfPageIsSkipped } from "./Main.js"; -import { Sidebar } from "./sidebar.js"; -import { NavigationSidebar } from "./NavigationSidebar.js"; - -// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) -import "../../assets/css/custom.css"; - -// Global styles to apply with the dashboard -import "../../assets/css/variables.css"; -import "../../assets/css/nativize.css"; -import "../../assets/css/global.css"; -import "../../assets/css/nav.css"; -import "../../assets/css/section.css"; -import "../../assets/css/demo.css"; -import "../../assets/css/individualtab.css"; -import "../../assets/css/main_tabs.css"; -// import "../../node_modules/cropperjs/dist/cropper.css" -import "../../../../node_modules/notyf/notyf.min.css"; -import "../../assets/css/spur.css"; -import "../../assets/css/main.css"; -// import "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" -import "../../../../node_modules/@fortawesome/fontawesome-free/css/all.css"; -// import "../../node_modules/select2/dist/css/select2.min.css" -// import "../../node_modules/@toast-ui/editor/dist/toastui-editor.css" -// import "../../node_modules/codemirror/lib/codemirror.css" -// import "../../node_modules/@yaireo/tagify/dist/tagify.css" -import "../../../../node_modules/fomantic-ui/dist/semantic.min.css"; -import "../../../../node_modules/fomantic-ui/dist/components/accordion.min.css"; -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 "../electron/check.js"; -import { isStorybook, reloadPageToHome } from "../dependencies/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 }, - name: { type: String, reflect: true }, - logo: { type: String, reflect: true }, - activePage: { type: String, reflect: true }, - globalState: { type: Object, reflect: true }, - }; - } - - main; - sidebar; - subSidebar; - - // Custom Getter / Setter for Subtitle - #subtitle; - set subtitle(v) { - this.#subtitle = v; - this.sidebar.subtitle = v; - } - - get subtitle() { - return this.#subtitle; - } - - pagesById = {}; - page; - - next = () => this.main.next(); - back = () => this.main.back(); - - constructor(props = {}) { - super(); - - this.main = new Main(); - this.main.classList.add("dash-app"); - - this.sidebar = new Sidebar(); - this.sidebar.onClick = (_, value) => { - const id = value.info.id; - if (this.page) this.page.to(id); - else this.setAttribute("activePage", id); - }; - - this.subSidebar = new NavigationSidebar(); - this.subSidebar.onClick = async (id) => this.page.to(id); - - this.pages = props.pages ?? {}; - this.name = props.name; - this.logo = props.logo; - this.renderNameInSidebar = props.renderNameInSidebar ?? true; - - this.globalState = props.globalState; // Impose a static global state on pages that have none - - if (props.activePage) this.setAttribute("activePage", props.activePage); - - // Handle all pop and push state updates - const pushState = window.history.pushState; - - const pushPopListener = (popEvent) => { - if (popEvent.state) { - const titleString = popEvent.state.title ?? popEvent.state.label; - document.title = `${titleString} - ${this.name}`; - const page = this.pagesById[popEvent.state.page]; // ?? this.pagesById[this.#activatePage] - if (!page) return; - if (page === this.page) return; // Do not rerender current page - this.setMain(page); - } - }; - - window.history.pushState = function (state) { - pushPopListener({ state: state }); - return pushState.apply(window.history, arguments); - }; - - window.addEventListener("popstate", pushPopListener); - window.addEventListener("pushstate", pushPopListener); - - this.#updated(); - } - - requestPageUpdate() { - if (this.page) this.page.requestUpdate(); - } - - createRenderRoot() { - return this; - } - - attributeChangedCallback(key, _, latest) { - super.attributeChangedCallback(...arguments); - if (this.sidebar && (key === "name" || key === "logo")) this.sidebar[key] = latest; - else if (key === "renderNameInSidebar") this.sidebar.renderName = latest === "true" || latest === true; - else if (key === "pages") this.#updated(latest); - else if (key.toLowerCase() === "activepage") { - if (this.page && this.page.info.parent && this.page.info.section) { - const currentProject = getCurrentProjectName(); - if (currentProject) updateAppProgress(latest, currentProject); - } - - while (latest && !this.pagesById[latest]) latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page - - // Update sidebar states - - this.sidebar.selectItem(latest); // Just highlight the item - this.sidebar.initialize = false; - this.#activatePage(latest); - return; - } else if (key.toLowerCase() === "globalstate" && this.page) { - this.page.info.globalState = JSON.parse(latest); - this.page.requestUpdate(); - } - } - - getPage(entry) { - if (!entry) return reloadPageToHome(); - const page = entry.page ?? entry; - if (page instanceof HTMLElement) return page; - else if (typeof page === "object") return this.getPage(Object.values(page)[0]); - } - - updateSections({ sidebar = true, main = false } = {}, globalState = this.page.info.globalState) { - const info = this.page.info; - let parent = info.parent; - - if (sidebar) { - this.subSidebar.sections = this.#getSections(parent.info.pages, globalState); // Update sidebar items (if changed) - } - - const { sections } = this.subSidebar; - - if (main) { - if (this.page.header) delete this.page.header.sections; // Ensure sections are updated - this.main.set({ - page: this.page, - sections, - }); - } - - return sections; - } - - setMain(page) { - window.getSelection().empty(); // Remove user selection before transitioning - - // Update Previous Page - const info = page.info; - const previous = this.page; - - // if (previous === page) return // Prevent rerendering the same page - - const isNested = info.parent && info.section; - - const toPass = {}; - if (previous) { - previous.dismiss(); // Dismiss all notifications for this page - if (previous.info.globalState) toPass.globalState = previous.info.globalState; // Pass global state over if appropriate - previous.active = false; - } - - // On initial reload, load global state if you can - if (isNested && !("globalState" in toPass)) toPass.globalState = this.globalState ?? page.load(); - - // Update Active Page - this.page = page; - - // Reset global state if page has no parent - if (!this.page.info.parent) toPass.globalState = {}; - - if (isNested) { - let parent = info.parent; - while (parent.info.parent) parent = parent.info.parent; // Lock sections to the top-level parent - this.updateSections({ sidebar: true }, toPass.globalState); - this.subSidebar.active = info.id; // Update active item (if changed) - this.sidebar.hide(true); - this.subSidebar.show(); - } else { - this.sidebar.show(); - this.subSidebar.hide(); - } - - this.page.set(toPass, false); - - this.page.checkSyncState().then(async () => { - const projectName = info.globalState?.project?.name; - - this.subSidebar.header = projectName - ? `

${projectName}

Conversion Pipeline` - : projectName; - - this.updateSections({ sidebar: false, main: true }); - - if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready - - const { skipped } = this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {}; - - if (skipped) { - if (isStorybook) return; // Do not skip on storybook - - // Run skip functions - Object.entries(page.workflow).forEach(([key, state]) => { - if (typeof state.skip === "function") state.skip(); - }); - - // Skip right over the page if configured as such - if (previous && previous.info.previous === this.page) await this.page.onTransition(-1); - else await this.page.onTransition(1); - } - }); - } - - // Populate the sections tracked for this page by using the global state as a model - #getSections = (pages = {}, globalState = {}) => { - if (!globalState.sections) globalState.sections = {}; - - Object.entries(pages).forEach(([id, page]) => { - const info = page.info; - if (info.id) id = info.id; - - if (info.section) { - const section = info.section; - - let state = globalState.sections[section]; - if (!state) - state = globalState.sections[section] = { - open: false, - active: false, - pages: {}, - }; - - let pageState = state.pages[id]; - if (!pageState) - pageState = state.pages[id] = { - visited: false, - active: false, - saved: false, - pageLabel: page.info.label, - pageTitle: page.info.title, - }; - - info.states = pageState; - - state.active = false; - pageState.active = false; - - // Check if page is skipped based on workflow state (if applicable) - pageState.skipped = checkIfPageIsSkipped(page, globalState.project?.workflow); - - if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states - - if (!("visited" in pageState)) pageState.visited = false; - if (id === this.page.info.id) state.active = pageState.visited = pageState.active = true; // Set active page as visited - } - }); - - return (globalState.sections = { ...globalState.sections }); // Update global state with new reference (to ensure re-render) - }; - - #transitionPromise = {}; - - #updated(pages = this.pages) { - const url = new URL(window.location.href); - let active = url.pathname.slice(1); - if (isElectron || isStorybook) active = new URLSearchParams(url.search).get("page"); - if (!active) active = this.activePage; // default to active page - - this.main.onTransition = async (transition) => { - const promise = (this.#transitionPromise.value = new Promise( - (resolve) => (this.#transitionPromise.trigger = resolve) - )); - - if (typeof transition === "number") { - const info = this.page.info; - const sign = Math.sign(transition); - if (sign === 1) transition = info.next.info.id; - else if (sign === -1) transition = (info.previous ?? info.parent).info.id; // Default to back in time - } - - this.setAttribute("activePage", transition); - - return promise; - }; - - this.main.updatePages = () => { - this.#updated(); // Rerender with new pages - this.setAttribute("activePage", this.page.info.id); // Re-render the current page - }; - - this.pagesById = {}; - Object.entries(pages).forEach((arr) => this.addPage(this.pagesById, arr)); - this.sidebar.pages = pages; - - if (active) this.setAttribute("activePage", active); - } - - #activatePage = (id) => { - const page = this.getPage(this.pagesById[id]); - - if (page) { - const { id, label } = page.info; - const queries = new URLSearchParams(window.location.search); - queries.set("page", id); - const project = queries.get("project"); - const value = - isElectron || isStorybook - ? `?${queries}` - : `${window.location.origin}/${id === "/" ? "" : id}?${queries}`; - history.pushState({ page: id, label, project }, label, value); - } - }; - - // Track Pages By Id - addPage = (acc, arr) => { - let [id, page] = arr; - - const info = page.info; - - if (info.id) id = info.id; - else page.info.id = id; // update id - - const pages = info.pages; - - // NOTE: This is not true for nested pages with more info... - if (page instanceof HTMLElement) acc[id] = page; - - if (pages) { - const pagesArr = Object.values(pages); - - const originalNext = page.info.next; - page.info.next = pagesArr[0]; // Next is the first nested page - - // Update info with relative information - Object.entries(pages).forEach(([newId, nestedPage], i) => { - nestedPage.info.base = id; - - const previousPage = pagesArr[i - 1]; - nestedPage.info.previous = - (previousPage?.info?.pages ? Object.values(previousPage.info.pages).pop() : previousPage) ?? page; // Previous is the previous nested page or the parent page - nestedPage.info.next = pagesArr[i + 1] ?? originalNext; // Next is the next nested page or the original next page - nestedPage.info.id = `${id}/${newId}`; - nestedPage.info.parent = page; - }); - - // Register all pages - Object.entries(pages).forEach((arr) => this.addPage(acc, arr)); - } - - return acc; - }; - - #first = true; - updated() { - if (this.#first) { - this.#first = false; - this.#updated(); - } - } - - render() { - this.style.width = "100%"; - this.style.height = "100%"; - this.style.display = "grid"; - this.style.gridTemplateColumns = "fit-content(0px) 1fr"; - this.style.position = "relative"; - this.main.style.height = "100vh"; - - if (this.name) this.sidebar.name = this.name; - if (this.logo) this.sidebar.logo = this.logo; - if ("renderNameInSidebar" in this) this.sidebar.renderName = this.renderNameInSidebar; - - return html` -
${this.sidebar} ${this.subSidebar}
- ${this.main} - `; - } -} - -customElements.get("nwb-dashboard") || customElements.define("nwb-dashboard", Dashboard); +import { LitElement, html } from "lit"; +import useGlobalStyles from "./utils/useGlobalStyles.js"; + +import { Main, checkIfPageIsSkipped } from "./Main.js"; +import { Sidebar } from "./sidebar.js"; +import { NavigationSidebar } from "./NavigationSidebar.js"; + +// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) +import "../../assets/css/custom.css"; + +// Global styles to apply with the dashboard +import "../../assets/css/variables.css"; +import "../../assets/css/nativize.css"; +import "../../assets/css/global.css"; +import "../../assets/css/nav.css"; +import "../../assets/css/section.css"; +import "../../assets/css/demo.css"; +import "../../assets/css/individualtab.css"; +import "../../assets/css/main_tabs.css"; +// import "../../node_modules/cropperjs/dist/cropper.css" +import "../../../../../node_modules/notyf/notyf.min.css"; +import "../../assets/css/spur.css"; +import "../../assets/css/main.css"; +// import "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" +import "../../../../../node_modules/@fortawesome/fontawesome-free/css/all.css"; +// import "../../node_modules/select2/dist/css/select2.min.css" +// import "../../node_modules/@toast-ui/editor/dist/toastui-editor.css" +// import "../../node_modules/codemirror/lib/codemirror.css" +// import "../../node_modules/@yaireo/tagify/dist/tagify.css" +import "../../../../../node_modules/fomantic-ui/dist/semantic.min.css"; +import "../../../../../node_modules/fomantic-ui/dist/components/accordion.min.css"; +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 { 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 }, + name: { type: String, reflect: true }, + logo: { type: String, reflect: true }, + activePage: { type: String, reflect: true }, + globalState: { type: Object, reflect: true }, + }; + } + + main; + sidebar; + subSidebar; + + // Custom Getter / Setter for Subtitle + #subtitle; + set subtitle(v) { + this.#subtitle = v; + this.sidebar.subtitle = v; + } + + get subtitle() { + return this.#subtitle; + } + + pagesById = {}; + page; + + next = () => this.main.next(); + back = () => this.main.back(); + + constructor(props = {}) { + super(); + + this.main = new Main(); + this.main.classList.add("dash-app"); + + this.sidebar = new Sidebar(); + this.sidebar.onClick = (_, value) => { + const id = value.info.id; + if (this.page) this.page.to(id); + else this.setAttribute("activePage", id); + }; + + this.subSidebar = new NavigationSidebar(); + this.subSidebar.onClick = async (id) => this.page.to(id); + + this.pages = props.pages ?? {}; + this.name = props.name; + this.logo = props.logo; + this.renderNameInSidebar = props.renderNameInSidebar ?? true; + + this.globalState = props.globalState; // Impose a static global state on pages that have none + + if (props.activePage) this.setAttribute("activePage", props.activePage); + + // Handle all pop and push state updates + const pushState = window.history.pushState; + + const pushPopListener = (popEvent) => { + if (popEvent.state) { + const titleString = popEvent.state.title ?? popEvent.state.label; + document.title = `${titleString} - ${this.name}`; + const page = this.pagesById[popEvent.state.page]; // ?? this.pagesById[this.#activatePage] + if (!page) return; + if (page === this.page) return; // Do not rerender current page + this.setMain(page); + } + }; + + window.history.pushState = function (state) { + pushPopListener({ state: state }); + return pushState.apply(window.history, arguments); + }; + + window.addEventListener("popstate", pushPopListener); + window.addEventListener("pushstate", pushPopListener); + + this.#updated(); + } + + requestPageUpdate() { + if (this.page) this.page.requestUpdate(); + } + + createRenderRoot() { + return this; + } + + attributeChangedCallback(key, _, latest) { + super.attributeChangedCallback(...arguments); + if (this.sidebar && (key === "name" || key === "logo")) + this.sidebar[key] = latest; + else if (key === "renderNameInSidebar") + this.sidebar.renderName = latest === "true" || latest === true; + else if (key === "pages") this.#updated(latest); + else if (key.toLowerCase() === "activepage") { + if (this.page && this.page.info.parent && this.page.info.section) { + const currentProject = getCurrentProjectName(); + if (currentProject) updateAppProgress(latest, currentProject); + } + + while (latest && !this.pagesById[latest]) + latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page + + // Update sidebar states + + this.sidebar.selectItem(latest); // Just highlight the item + this.sidebar.initialize = false; + this.#activatePage(latest); + return; + } else if (key.toLowerCase() === "globalstate" && this.page) { + this.page.info.globalState = JSON.parse(latest); + this.page.requestUpdate(); + } + } + + getPage(entry) { + if (!entry) return reloadPageToHome(); + const page = entry.page ?? entry; + if (page instanceof HTMLElement) return page; + else if (typeof page === "object") + return this.getPage(Object.values(page)[0]); + } + + updateSections( + { sidebar = true, main = false } = {}, + globalState = this.page.info.globalState, + ) { + const info = this.page.info; + let parent = info.parent; + + if (sidebar) { + this.subSidebar.sections = this.#getSections( + parent.info.pages, + globalState, + ); // Update sidebar items (if changed) + } + + const { sections } = this.subSidebar; + + if (main) { + if (this.page.header) delete this.page.header.sections; // Ensure sections are updated + this.main.set({ + page: this.page, + sections, + }); + } + + return sections; + } + + setMain(page) { + window.getSelection().empty(); // Remove user selection before transitioning + + // Update Previous Page + const info = page.info; + const previous = this.page; + + // if (previous === page) return // Prevent rerendering the same page + + const isNested = info.parent && info.section; + + const toPass = {}; + if (previous) { + previous.dismiss(); // Dismiss all notifications for this page + if (previous.info.globalState) + toPass.globalState = previous.info.globalState; // Pass global state over if appropriate + previous.active = false; + } + + // On initial reload, load global state if you can + if (isNested && !("globalState" in toPass)) + toPass.globalState = this.globalState ?? page.load(); + + // Update Active Page + this.page = page; + + // Reset global state if page has no parent + if (!this.page.info.parent) toPass.globalState = {}; + + if (isNested) { + let parent = info.parent; + while (parent.info.parent) parent = parent.info.parent; // Lock sections to the top-level parent + this.updateSections({ sidebar: true }, toPass.globalState); + this.subSidebar.active = info.id; // Update active item (if changed) + this.sidebar.hide(true); + this.subSidebar.show(); + } else { + this.sidebar.show(); + this.subSidebar.hide(); + } + + this.page.set(toPass, false); + + this.page.checkSyncState().then(async () => { + const projectName = info.globalState?.project?.name; + + this.subSidebar.header = projectName + ? `

${projectName}

Conversion Pipeline` + : projectName; + + this.updateSections({ sidebar: false, main: true }); + + if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready + + const { skipped } = + this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {}; + + if (skipped) { + if (isStorybook) return; // Do not skip on storybook + + // Run skip functions + Object.entries(page.workflow).forEach(([key, state]) => { + if (typeof state.skip === "function") state.skip(); + }); + + // Skip right over the page if configured as such + if (previous && previous.info.previous === this.page) + await this.page.onTransition(-1); + else await this.page.onTransition(1); + } + }); + } + + // Populate the sections tracked for this page by using the global state as a model + #getSections = (pages = {}, globalState = {}) => { + if (!globalState.sections) globalState.sections = {}; + + Object.entries(pages).forEach(([id, page]) => { + const info = page.info; + if (info.id) id = info.id; + + if (info.section) { + const section = info.section; + + let state = globalState.sections[section]; + if (!state) + state = globalState.sections[section] = { + open: false, + active: false, + pages: {}, + }; + + let pageState = state.pages[id]; + if (!pageState) + pageState = state.pages[id] = { + visited: false, + active: false, + saved: false, + pageLabel: page.info.label, + pageTitle: page.info.title, + }; + + info.states = pageState; + + state.active = false; + pageState.active = false; + + // Check if page is skipped based on workflow state (if applicable) + pageState.skipped = checkIfPageIsSkipped( + page, + globalState.project?.workflow, + ); + + if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states + + if (!("visited" in pageState)) pageState.visited = false; + if (id === this.page.info.id) + state.active = pageState.visited = pageState.active = true; // Set active page as visited + } + }); + + return (globalState.sections = { ...globalState.sections }); // Update global state with new reference (to ensure re-render) + }; + + #transitionPromise = {}; + + #updated(pages = this.pages) { + const url = new URL(window.location.href); + let active = url.pathname.slice(1); + if (isElectron || isStorybook) + active = new URLSearchParams(url.search).get("page"); + if (!active) active = this.activePage; // default to active page + + this.main.onTransition = async (transition) => { + const promise = (this.#transitionPromise.value = new Promise( + (resolve) => (this.#transitionPromise.trigger = resolve), + )); + + if (typeof transition === "number") { + const info = this.page.info; + const sign = Math.sign(transition); + if (sign === 1) transition = info.next.info.id; + else if (sign === -1) + transition = (info.previous ?? info.parent).info.id; // Default to back in time + } + + this.setAttribute("activePage", transition); + + return promise; + }; + + this.main.updatePages = () => { + this.#updated(); // Rerender with new pages + this.setAttribute("activePage", this.page.info.id); // Re-render the current page + }; + + this.pagesById = {}; + Object.entries(pages).forEach((arr) => this.addPage(this.pagesById, arr)); + this.sidebar.pages = pages; + + if (active) this.setAttribute("activePage", active); + } + + #activatePage = (id) => { + const page = this.getPage(this.pagesById[id]); + + if (page) { + const { id, label } = page.info; + const queries = new URLSearchParams(window.location.search); + queries.set("page", id); + const project = queries.get("project"); + const value = + isElectron || isStorybook + ? `?${queries}` + : `${window.location.origin}/${id === "/" ? "" : id}?${queries}`; + history.pushState({ page: id, label, project }, label, value); + } + }; + + // Track Pages By Id + addPage = (acc, arr) => { + let [id, page] = arr; + + const info = page.info; + + if (info.id) id = info.id; + else page.info.id = id; // update id + + const pages = info.pages; + + // NOTE: This is not true for nested pages with more info... + if (page instanceof HTMLElement) acc[id] = page; + + if (pages) { + const pagesArr = Object.values(pages); + + const originalNext = page.info.next; + page.info.next = pagesArr[0]; // Next is the first nested page + + // Update info with relative information + Object.entries(pages).forEach(([newId, nestedPage], i) => { + nestedPage.info.base = id; + + const previousPage = pagesArr[i - 1]; + nestedPage.info.previous = + (previousPage?.info?.pages + ? Object.values(previousPage.info.pages).pop() + : previousPage) ?? page; // Previous is the previous nested page or the parent page + nestedPage.info.next = pagesArr[i + 1] ?? originalNext; // Next is the next nested page or the original next page + nestedPage.info.id = `${id}/${newId}`; + nestedPage.info.parent = page; + }); + + // Register all pages + Object.entries(pages).forEach((arr) => this.addPage(acc, arr)); + } + + return acc; + }; + + #first = true; + updated() { + if (this.#first) { + this.#first = false; + this.#updated(); + } + } + + render() { + this.style.width = "100%"; + this.style.height = "100%"; + this.style.display = "grid"; + this.style.gridTemplateColumns = "fit-content(0px) 1fr"; + this.style.position = "relative"; + this.main.style.height = "100vh"; + + if (this.name) this.sidebar.name = this.name; + if (this.logo) this.sidebar.logo = this.logo; + if ("renderNameInSidebar" in this) + this.sidebar.renderName = this.renderNameInSidebar; + + return html` +
${this.sidebar} ${this.subSidebar}
+ ${this.main} + `; + } +} + +customElements.get("nwb-dashboard") || + customElements.define("nwb-dashboard", Dashboard); diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index ed59dfdcbb..7f5eb3a51d 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,445 +1,480 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils.js"; - -import { homeDirectory, notyf, testDataFolderPath } from "../../../dependencies.js"; -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, - onUpdateAvailable, - onUpdateProgress, - registerUpdateProgress, -} from "../../../../../utils/electron.js"; - -import saveSVG from "../../assets/save.svg?raw"; -import folderSVG from "../../assets/folder_open.svg?raw"; -import deleteSVG from "../../assets/delete.svg?raw"; -import generateSVG from "../../assets/restart.svg?raw"; -import downloadSVG from "../../assets/download.svg?raw"; -import infoSVG from "../../assets/info.svg?raw"; - -import { header } from "../../forms/utils"; - -import testingSuiteYaml from "../../../../../../guide_testing_suite.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals.js"; -import { Modal } from "../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - } -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - } - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - } - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error" - ); - - const { pipelines = {} } = testingSuiteYaml; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning" - ); - } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error" - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify(`Test dataset successfully deleted from your system.`); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); - else { - this.notify("The test dataset no longer exists!", "warning"); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send("showItemInFolder", output_path); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, +} from "../../../../utils/electron.js"; + +import saveSVG from "../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../assets/icons/delete.svg?raw"; +import generateSVG from "../../../../assets/icons/restart.svg?raw"; +import downloadSVG from "../../../../assets/download.svg?raw"; +import infoSVG from "../../../../assets/info.svg?raw"; + +import { header } from "../../forms/utils"; + +import examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath( + testDataFolderPath, + "multi_session_dataset", +); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => + fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone( + info.interfaces ? info.metadata ?? {} : {}, + ); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + }, +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) + return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + }, + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + }, + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify( + `Test dataset successfully generated at ${sanitizedOutputPath}!`, + ); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) + return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) + return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error", + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce( + (acc, v) => (acc += v === true ? 1 : 0), + 0, + ); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning", + ); + } else if (nSuccessful) + this.#openNotyf( + `Generated ${nSuccessful} test pipelines.`, + "success", + ); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error", + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement([ + "developer", + "testing_data_folder", + ]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && + fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify( + `Test dataset successfully deleted from your system.`, + ); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send( + "showItemInFolder", + DATASET_OUTPUT_PATH, + ); + else { + this.notify( + "The test dataset no longer exists!", + "warning", + ); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send( + "showItemInFolder", + output_path, + ); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || + customElements.define("nwbguide-settings-page", SettingsPage); diff --git a/src/electron/frontend/core/pages.js b/src/electron/frontend/core/pages.js index 12bfafee97..7ba96d356a 100644 --- a/src/electron/frontend/core/pages.js +++ b/src/electron/frontend/core/pages.js @@ -1,195 +1,194 @@ -import { GettingStartedPage } from "./stories/pages/getting-started/GettingStarted"; -import { DocumentationPage } from "./stories/pages/documentation/Documentation"; -import { ContactPage } from "./stories/pages/contact-us/Contact"; -import { GuidedHomePage } from "./stories/pages/guided-mode/GuidedHome"; -import { GuidedNewDatasetPage } from "./stories/pages/guided-mode/setup/GuidedNewDatasetInfo"; -import { GuidedStructurePage } from "./stories/pages/guided-mode/data/GuidedStructure"; -import { sections } from "./stories/pages/globals"; -import { GuidedSubjectsPage } from "./stories/pages/guided-mode/setup/GuidedSubjects"; -import { GuidedSourceDataPage } from "./stories/pages/guided-mode/data/GuidedSourceData"; -import { GuidedMetadataPage } from "./stories/pages/guided-mode/data/GuidedMetadata"; -import { GuidedUploadPage } from "./stories/pages/guided-mode/options/GuidedUpload"; -import { GuidedResultsPage } from "./stories/pages/guided-mode/results/GuidedResults"; -import { Dashboard } from "./stories/Dashboard"; -import { GuidedStubPreviewPage } from "./stories/pages/guided-mode/options/GuidedStubPreview"; -import { GuidedInspectorPage } from "./stories/pages/guided-mode/options/GuidedInspectorPage"; - -import logo from "../assets/img/logo-guide-draft-transparent-tight.png"; -import { GuidedPathExpansionPage } from "./stories/pages/guided-mode/data/GuidedPathExpansion"; -import uploadIcon from "./stories/assets/dandi.svg?raw"; -import inspectIcon from "./stories/assets/inspect.svg?raw"; -import neurosiftIcon from "./stories/assets/neurosift-logo.svg?raw"; - -import settingsIcon from "./stories/assets/settings.svg?raw"; - -import { UploadsPage } from "./stories/pages/uploads/UploadsPage"; -import { SettingsPage } from "./stories/pages/settings/SettingsPage"; -import { InspectPage } from "./stories/pages/inspect/InspectPage"; -import { PreviewPage } from "./stories/pages/preview/PreviewPage"; -import { GuidedPreform } from "./stories/pages/guided-mode/setup/Preform"; -import { GuidedDandiResultsPage } from "./stories/pages/guided-mode/results/GuidedDandiResults"; - -let dashboard = document.querySelector("nwb-dashboard"); -if (!dashboard) dashboard = new Dashboard(); -dashboard.logo = logo; -dashboard.name = "NWB GUIDE"; -dashboard.renderNameInSidebar = false; - -const resourcesGroup = "Resources"; - -const guidedIcon = ` - - - - -`; - -const documentationIcon = ` - - -`; - -const contactIcon = ` - -`; - -const pages = { - "/": new GuidedHomePage({ - label: "Convert", - icon: guidedIcon, - pages: { - details: new GuidedNewDatasetPage({ - title: "Project Setup", - label: "Project details", - section: sections[0], - }), - - workflow: new GuidedPreform({ - title: "Pipeline Workflow", - label: "Pipeline workflow", - section: sections[0], - }), - - structure: new GuidedStructurePage({ - title: "Provide Data Formats", - label: "Data formats", - section: sections[0], - }), - - locate: new GuidedPathExpansionPage({ - title: "Locate Data", - label: "Locate data", - section: sections[0], - }), - - subjects: new GuidedSubjectsPage({ - title: "Subject Metadata", - label: "Subject details", - section: sections[0], - }), - - sourcedata: new GuidedSourceDataPage({ - title: "Source Data Information", - label: "Source data", - section: sections[1], - }), - - metadata: new GuidedMetadataPage({ - title: "File Metadata", - label: "File metadata", - section: sections[1], - }), - - inspect: new GuidedInspectorPage({ - title: "Inspector Report", - label: "Validate metadata", - section: sections[2], - sync: ["preview"], - }), - - preview: new GuidedStubPreviewPage({ - title: "Conversion Preview", - label: "Preview NWB files", - section: sections[2], - sync: ["preview"], - }), - - conversion: new GuidedResultsPage({ - title: "Conversion Review", - label: "Review conversion", - section: sections[2], - sync: ["conversion"], - }), - - upload: new GuidedUploadPage({ - title: "DANDI Upload", - label: "Upload to DANDI", - section: sections[3], - sync: ["conversion"], - }), - - review: new GuidedDandiResultsPage({ - title: "Upload Review", - label: "Review published data", - section: sections[3], - }), - }, - }), - validate: new InspectPage({ - label: "Validate", - icon: inspectIcon, - }), - explore: new PreviewPage({ - label: "Explore", - icon: neurosiftIcon, - }), - uploads: new UploadsPage({ - label: "Upload", - icon: uploadIcon, - }), - docs: new DocumentationPage({ - label: "Documentation", - icon: documentationIcon, - group: resourcesGroup, - }), - contact: new ContactPage({ - label: "Contact Us", - icon: contactIcon, - group: resourcesGroup, - }), - settings: new SettingsPage({ - label: "Settings", - icon: settingsIcon, - group: "bottom", - }), -}; - -dashboard.pages = pages; - -export { dashboard }; +import { DocumentationPage } from "./components/pages/documentation/Documentation"; +import { ContactPage } from "./components/pages/contact-us/Contact"; +import { GuidedHomePage } from "./components/pages/guided-mode/GuidedHome"; +import { GuidedNewDatasetPage } from "./components/pages/guided-mode/setup/GuidedNewDatasetInfo"; +import { GuidedStructurePage } from "./components/pages/guided-mode/data/GuidedStructure"; +import { sections } from "./components/pages/globals"; +import { GuidedSubjectsPage } from "./components/pages/guided-mode/setup/GuidedSubjects"; +import { GuidedSourceDataPage } from "./components/pages/guided-mode/data/GuidedSourceData"; +import { GuidedMetadataPage } from "./components/pages/guided-mode/data/GuidedMetadata"; +import { GuidedUploadPage } from "./components/pages/guided-mode/options/GuidedUpload"; +import { GuidedResultsPage } from "./components/pages/guided-mode/results/GuidedResults"; +import { Dashboard } from "./components/Dashboard"; +import { GuidedStubPreviewPage } from "./components/pages/guided-mode/options/GuidedStubPreview"; +import { GuidedInspectorPage } from "./components/pages/guided-mode/options/GuidedInspectorPage"; + +import logo from "../assets/img/logo-guide-draft-transparent-tight.png"; +import { GuidedPathExpansionPage } from "./components/pages/guided-mode/data/GuidedPathExpansion"; +import uploadIcon from "../assets/icons/dandi.svg?raw"; +import inspectIcon from "../assets/icons/inspect.svg?raw"; +import neurosiftIcon from "../assets/icons/neurosift-logo.svg?raw"; + +import settingsIcon from "../assets/icons/settings.svg?raw"; + +import { UploadsPage } from "./components/pages/uploads/UploadsPage"; +import { SettingsPage } from "./components/pages/settings/SettingsPage"; +import { InspectPage } from "./components/pages/inspect/InspectPage"; +import { PreviewPage } from "./components/pages/preview/PreviewPage"; +import { GuidedPreform } from "./components/pages/guided-mode/setup/Preform"; +import { GuidedDandiResultsPage } from "./components/pages/guided-mode/results/GuidedDandiResults"; + +let dashboard = document.querySelector("nwb-dashboard"); +if (!dashboard) dashboard = new Dashboard(); +dashboard.logo = logo; +dashboard.name = "NWB GUIDE"; +dashboard.renderNameInSidebar = false; + +const resourcesGroup = "Resources"; + +const guidedIcon = ` + + + + +`; + +const documentationIcon = ` + + +`; + +const contactIcon = ` + +`; + +const pages = { + "/": new GuidedHomePage({ + label: "Convert", + icon: guidedIcon, + pages: { + details: new GuidedNewDatasetPage({ + title: "Project Setup", + label: "Project details", + section: sections[0], + }), + + workflow: new GuidedPreform({ + title: "Pipeline Workflow", + label: "Pipeline workflow", + section: sections[0], + }), + + structure: new GuidedStructurePage({ + title: "Provide Data Formats", + label: "Data formats", + section: sections[0], + }), + + locate: new GuidedPathExpansionPage({ + title: "Locate Data", + label: "Locate data", + section: sections[0], + }), + + subjects: new GuidedSubjectsPage({ + title: "Subject Metadata", + label: "Subject details", + section: sections[0], + }), + + sourcedata: new GuidedSourceDataPage({ + title: "Source Data Information", + label: "Source data", + section: sections[1], + }), + + metadata: new GuidedMetadataPage({ + title: "File Metadata", + label: "File metadata", + section: sections[1], + }), + + inspect: new GuidedInspectorPage({ + title: "Inspector Report", + label: "Validate metadata", + section: sections[2], + sync: ["preview"], + }), + + preview: new GuidedStubPreviewPage({ + title: "Conversion Preview", + label: "Preview NWB files", + section: sections[2], + sync: ["preview"], + }), + + conversion: new GuidedResultsPage({ + title: "Conversion Review", + label: "Review conversion", + section: sections[2], + sync: ["conversion"], + }), + + upload: new GuidedUploadPage({ + title: "DANDI Upload", + label: "Upload to DANDI", + section: sections[3], + sync: ["conversion"], + }), + + review: new GuidedDandiResultsPage({ + title: "Upload Review", + label: "Review published data", + section: sections[3], + }), + }, + }), + validate: new InspectPage({ + label: "Validate", + icon: inspectIcon, + }), + explore: new PreviewPage({ + label: "Explore", + icon: neurosiftIcon, + }), + uploads: new UploadsPage({ + label: "Upload", + icon: uploadIcon, + }), + docs: new DocumentationPage({ + label: "Documentation", + icon: documentationIcon, + group: resourcesGroup, + }), + contact: new ContactPage({ + label: "Contact Us", + icon: contactIcon, + group: resourcesGroup, + }), + settings: new SettingsPage({ + label: "Settings", + icon: settingsIcon, + group: "bottom", + }), +}; + +dashboard.pages = pages; + +export { dashboard }; diff --git a/src/electron/frontend/utils/electron.js b/src/electron/frontend/utils/electron.js index f9b73df5f1..88a8155e85 100644 --- a/src/electron/frontend/utils/electron.js +++ b/src/electron/frontend/utils/electron.js @@ -1,86 +1,98 @@ -import { updateURLParams } from "../../utils/url.js"; -import isElectron from "./check.js"; - -export { isElectron }; - -export let port = 4242; -export let SERVER_FILE_PATH = ""; -export const electron = globalThis.electron ?? {}; // ipcRenderer, remote, shell, etc. -export let fs = null; -export let os = null; -export let remote = {}; -export let app = null; -export let path = null; -export let log = null; -export let crypto = null; - -let updateAvailable = false; -const updateAvailableCallbacks = []; -export const onUpdateAvailable = (callback) => { - if (updateAvailable) callback(updateAvailable); - else updateAvailableCallbacks.push(callback); -}; - -let updateProgress = null; - -const updateProgressCallbacks = []; -export const onUpdateProgress = (callback) => { - if (updateProgress) callback(updateProgress); - else updateProgressCallbacks.push(callback); -}; - -export const registerUpdateProgress = (info) => { - updateProgress = info; - updateProgressCallbacks.forEach((cb) => cb(info)); -}; - -const registerUpdate = (info) => { - updateAvailable = info; - document.body.setAttribute("data-update-available", JSON.stringify(info)); - updateAvailableCallbacks.forEach((cb) => cb(info)); -}; - -// Used in tests -try { - crypto = require("crypto"); -} catch {} - -if (isElectron) { - try { - fs = require("fs-extra"); // File System - os = require("os"); - crypto = require("crypto"); - remote = require("@electron/remote"); - app = remote.app; - - electron.ipcRenderer.on("fileOpened", (info, filepath) => { - updateURLParams({ file: filepath }); - const dashboard = document.querySelector("nwb-dashboard"); - const activePage = dashboard.getAttribute("activePage"); - if (activePage === "preview") dashboard.requestUpdate(); - else dashboard.setAttribute("activePage", "preview"); - }); - - ["log", "warn", "error"].forEach((method) => - electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) - ); - - electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)); - - electron.ipcRenderer.on(`update-available`, (_, info) => (info ? registerUpdate(info) : "")); - - electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)); - electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)); - - electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)); - - port = electron.ipcRenderer.sendSync("get-port"); - console.log("User OS:", os.type(), os.platform(), "version:", os.release()); - - SERVER_FILE_PATH = electron.ipcRenderer.sendSync("get-server-file-path"); - - path = require("path"); - } catch (error) { - console.error("Electron API access failed —", error); - } -} else console.warn("Electron API is blocked for web builds"); +import { updateURLParams } from "./url.js"; + +var userAgent = navigator.userAgent.toLowerCase(); +export const isElectron = userAgent.indexOf(" electron/") > -1; + +export let port = 4242; +export let SERVER_FILE_PATH = ""; +export const electron = globalThis.electron ?? {}; // ipcRenderer, remote, shell, etc. +export let fs = null; +export let os = null; +export let remote = {}; +export let app = null; +export let path = null; +export let log = null; +export let crypto = null; + +let updateAvailable = false; +const updateAvailableCallbacks = []; +export const onUpdateAvailable = (callback) => { + if (updateAvailable) callback(updateAvailable); + else updateAvailableCallbacks.push(callback); +}; + +let updateProgress = null; + +const updateProgressCallbacks = []; +export const onUpdateProgress = (callback) => { + if (updateProgress) callback(updateProgress); + else updateProgressCallbacks.push(callback); +}; + +export const registerUpdateProgress = (info) => { + updateProgress = info; + updateProgressCallbacks.forEach((cb) => cb(info)); +}; + +const registerUpdate = (info) => { + updateAvailable = info; + document.body.setAttribute("data-update-available", JSON.stringify(info)); + updateAvailableCallbacks.forEach((cb) => cb(info)); +}; + +// Used in tests +try { + crypto = require("crypto"); +} catch {} + +if (isElectron) { + try { + fs = require("fs-extra"); // File System + os = require("os"); + crypto = require("crypto"); + remote = require("@electron/remote"); + app = remote.app; + + electron.ipcRenderer.on("fileOpened", (info, filepath) => { + updateURLParams({ file: filepath }); + const dashboard = document.querySelector("nwb-dashboard"); + const activePage = dashboard.getAttribute("activePage"); + if (activePage === "preview") dashboard.requestUpdate(); + else dashboard.setAttribute("activePage", "preview"); + }); + + ["log", "warn", "error"].forEach((method) => + electron.ipcRenderer.on(`console.${method}`, (_, ...args) => + console[method](`[main-process]:`, ...args), + ), + ); + + electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => + console.log(`[Update]:`, ...args), + ); + + electron.ipcRenderer.on(`update-available`, (_, info) => + info ? registerUpdate(info) : "", + ); + + electron.ipcRenderer.on(`update-progress`, (_, info) => + registerUpdateProgress(info), + ); + electron.ipcRenderer.on(`update-complete`, (_, ...args) => + console.log(`[Update]:`, ...args), + ); + + electron.ipcRenderer.on(`update-error`, (_, ...args) => + console.log(`[Update]:`, ...args), + ); + + port = electron.ipcRenderer.sendSync("get-port"); + console.log("User OS:", os.type(), os.platform(), "version:", os.release()); + + SERVER_FILE_PATH = electron.ipcRenderer.sendSync("get-server-file-path"); + + path = require("path"); + } catch (error) { + console.error("Electron API access failed —", error); + } +} else console.warn("Electron API is blocked for web builds"); From 8f2ec901675993bd1a7f399ec064935caa910c56 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 18:45:57 +0000 Subject: [PATCH 07/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../frontend/core/components/Dashboard.js | 908 +++++++++-------- .../components/pages/settings/SettingsPage.js | 918 +++++++++--------- src/electron/frontend/core/pages.js | 388 ++++---- src/electron/frontend/utils/electron.js | 184 ++-- 4 files changed, 1160 insertions(+), 1238 deletions(-) diff --git a/src/electron/frontend/core/components/Dashboard.js b/src/electron/frontend/core/components/Dashboard.js index c28ae1bf6a..f23e3baeec 100644 --- a/src/electron/frontend/core/components/Dashboard.js +++ b/src/electron/frontend/core/components/Dashboard.js @@ -1,466 +1,442 @@ -import { LitElement, html } from "lit"; -import useGlobalStyles from "./utils/useGlobalStyles.js"; - -import { Main, checkIfPageIsSkipped } from "./Main.js"; -import { Sidebar } from "./sidebar.js"; -import { NavigationSidebar } from "./NavigationSidebar.js"; - -// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) -import "../../assets/css/custom.css"; - -// Global styles to apply with the dashboard -import "../../assets/css/variables.css"; -import "../../assets/css/nativize.css"; -import "../../assets/css/global.css"; -import "../../assets/css/nav.css"; -import "../../assets/css/section.css"; -import "../../assets/css/demo.css"; -import "../../assets/css/individualtab.css"; -import "../../assets/css/main_tabs.css"; -// import "../../node_modules/cropperjs/dist/cropper.css" -import "../../../../../node_modules/notyf/notyf.min.css"; -import "../../assets/css/spur.css"; -import "../../assets/css/main.css"; -// import "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" -import "../../../../../node_modules/@fortawesome/fontawesome-free/css/all.css"; -// import "../../node_modules/select2/dist/css/select2.min.css" -// import "../../node_modules/@toast-ui/editor/dist/toastui-editor.css" -// import "../../node_modules/codemirror/lib/codemirror.css" -// import "../../node_modules/@yaireo/tagify/dist/tagify.css" -import "../../../../../node_modules/fomantic-ui/dist/semantic.min.css"; -import "../../../../../node_modules/fomantic-ui/dist/components/accordion.min.css"; -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 { 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 }, - name: { type: String, reflect: true }, - logo: { type: String, reflect: true }, - activePage: { type: String, reflect: true }, - globalState: { type: Object, reflect: true }, - }; - } - - main; - sidebar; - subSidebar; - - // Custom Getter / Setter for Subtitle - #subtitle; - set subtitle(v) { - this.#subtitle = v; - this.sidebar.subtitle = v; - } - - get subtitle() { - return this.#subtitle; - } - - pagesById = {}; - page; - - next = () => this.main.next(); - back = () => this.main.back(); - - constructor(props = {}) { - super(); - - this.main = new Main(); - this.main.classList.add("dash-app"); - - this.sidebar = new Sidebar(); - this.sidebar.onClick = (_, value) => { - const id = value.info.id; - if (this.page) this.page.to(id); - else this.setAttribute("activePage", id); - }; - - this.subSidebar = new NavigationSidebar(); - this.subSidebar.onClick = async (id) => this.page.to(id); - - this.pages = props.pages ?? {}; - this.name = props.name; - this.logo = props.logo; - this.renderNameInSidebar = props.renderNameInSidebar ?? true; - - this.globalState = props.globalState; // Impose a static global state on pages that have none - - if (props.activePage) this.setAttribute("activePage", props.activePage); - - // Handle all pop and push state updates - const pushState = window.history.pushState; - - const pushPopListener = (popEvent) => { - if (popEvent.state) { - const titleString = popEvent.state.title ?? popEvent.state.label; - document.title = `${titleString} - ${this.name}`; - const page = this.pagesById[popEvent.state.page]; // ?? this.pagesById[this.#activatePage] - if (!page) return; - if (page === this.page) return; // Do not rerender current page - this.setMain(page); - } - }; - - window.history.pushState = function (state) { - pushPopListener({ state: state }); - return pushState.apply(window.history, arguments); - }; - - window.addEventListener("popstate", pushPopListener); - window.addEventListener("pushstate", pushPopListener); - - this.#updated(); - } - - requestPageUpdate() { - if (this.page) this.page.requestUpdate(); - } - - createRenderRoot() { - return this; - } - - attributeChangedCallback(key, _, latest) { - super.attributeChangedCallback(...arguments); - if (this.sidebar && (key === "name" || key === "logo")) - this.sidebar[key] = latest; - else if (key === "renderNameInSidebar") - this.sidebar.renderName = latest === "true" || latest === true; - else if (key === "pages") this.#updated(latest); - else if (key.toLowerCase() === "activepage") { - if (this.page && this.page.info.parent && this.page.info.section) { - const currentProject = getCurrentProjectName(); - if (currentProject) updateAppProgress(latest, currentProject); - } - - while (latest && !this.pagesById[latest]) - latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page - - // Update sidebar states - - this.sidebar.selectItem(latest); // Just highlight the item - this.sidebar.initialize = false; - this.#activatePage(latest); - return; - } else if (key.toLowerCase() === "globalstate" && this.page) { - this.page.info.globalState = JSON.parse(latest); - this.page.requestUpdate(); - } - } - - getPage(entry) { - if (!entry) return reloadPageToHome(); - const page = entry.page ?? entry; - if (page instanceof HTMLElement) return page; - else if (typeof page === "object") - return this.getPage(Object.values(page)[0]); - } - - updateSections( - { sidebar = true, main = false } = {}, - globalState = this.page.info.globalState, - ) { - const info = this.page.info; - let parent = info.parent; - - if (sidebar) { - this.subSidebar.sections = this.#getSections( - parent.info.pages, - globalState, - ); // Update sidebar items (if changed) - } - - const { sections } = this.subSidebar; - - if (main) { - if (this.page.header) delete this.page.header.sections; // Ensure sections are updated - this.main.set({ - page: this.page, - sections, - }); - } - - return sections; - } - - setMain(page) { - window.getSelection().empty(); // Remove user selection before transitioning - - // Update Previous Page - const info = page.info; - const previous = this.page; - - // if (previous === page) return // Prevent rerendering the same page - - const isNested = info.parent && info.section; - - const toPass = {}; - if (previous) { - previous.dismiss(); // Dismiss all notifications for this page - if (previous.info.globalState) - toPass.globalState = previous.info.globalState; // Pass global state over if appropriate - previous.active = false; - } - - // On initial reload, load global state if you can - if (isNested && !("globalState" in toPass)) - toPass.globalState = this.globalState ?? page.load(); - - // Update Active Page - this.page = page; - - // Reset global state if page has no parent - if (!this.page.info.parent) toPass.globalState = {}; - - if (isNested) { - let parent = info.parent; - while (parent.info.parent) parent = parent.info.parent; // Lock sections to the top-level parent - this.updateSections({ sidebar: true }, toPass.globalState); - this.subSidebar.active = info.id; // Update active item (if changed) - this.sidebar.hide(true); - this.subSidebar.show(); - } else { - this.sidebar.show(); - this.subSidebar.hide(); - } - - this.page.set(toPass, false); - - this.page.checkSyncState().then(async () => { - const projectName = info.globalState?.project?.name; - - this.subSidebar.header = projectName - ? `

${projectName}

Conversion Pipeline` - : projectName; - - this.updateSections({ sidebar: false, main: true }); - - if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready - - const { skipped } = - this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {}; - - if (skipped) { - if (isStorybook) return; // Do not skip on storybook - - // Run skip functions - Object.entries(page.workflow).forEach(([key, state]) => { - if (typeof state.skip === "function") state.skip(); - }); - - // Skip right over the page if configured as such - if (previous && previous.info.previous === this.page) - await this.page.onTransition(-1); - else await this.page.onTransition(1); - } - }); - } - - // Populate the sections tracked for this page by using the global state as a model - #getSections = (pages = {}, globalState = {}) => { - if (!globalState.sections) globalState.sections = {}; - - Object.entries(pages).forEach(([id, page]) => { - const info = page.info; - if (info.id) id = info.id; - - if (info.section) { - const section = info.section; - - let state = globalState.sections[section]; - if (!state) - state = globalState.sections[section] = { - open: false, - active: false, - pages: {}, - }; - - let pageState = state.pages[id]; - if (!pageState) - pageState = state.pages[id] = { - visited: false, - active: false, - saved: false, - pageLabel: page.info.label, - pageTitle: page.info.title, - }; - - info.states = pageState; - - state.active = false; - pageState.active = false; - - // Check if page is skipped based on workflow state (if applicable) - pageState.skipped = checkIfPageIsSkipped( - page, - globalState.project?.workflow, - ); - - if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states - - if (!("visited" in pageState)) pageState.visited = false; - if (id === this.page.info.id) - state.active = pageState.visited = pageState.active = true; // Set active page as visited - } - }); - - return (globalState.sections = { ...globalState.sections }); // Update global state with new reference (to ensure re-render) - }; - - #transitionPromise = {}; - - #updated(pages = this.pages) { - const url = new URL(window.location.href); - let active = url.pathname.slice(1); - if (isElectron || isStorybook) - active = new URLSearchParams(url.search).get("page"); - if (!active) active = this.activePage; // default to active page - - this.main.onTransition = async (transition) => { - const promise = (this.#transitionPromise.value = new Promise( - (resolve) => (this.#transitionPromise.trigger = resolve), - )); - - if (typeof transition === "number") { - const info = this.page.info; - const sign = Math.sign(transition); - if (sign === 1) transition = info.next.info.id; - else if (sign === -1) - transition = (info.previous ?? info.parent).info.id; // Default to back in time - } - - this.setAttribute("activePage", transition); - - return promise; - }; - - this.main.updatePages = () => { - this.#updated(); // Rerender with new pages - this.setAttribute("activePage", this.page.info.id); // Re-render the current page - }; - - this.pagesById = {}; - Object.entries(pages).forEach((arr) => this.addPage(this.pagesById, arr)); - this.sidebar.pages = pages; - - if (active) this.setAttribute("activePage", active); - } - - #activatePage = (id) => { - const page = this.getPage(this.pagesById[id]); - - if (page) { - const { id, label } = page.info; - const queries = new URLSearchParams(window.location.search); - queries.set("page", id); - const project = queries.get("project"); - const value = - isElectron || isStorybook - ? `?${queries}` - : `${window.location.origin}/${id === "/" ? "" : id}?${queries}`; - history.pushState({ page: id, label, project }, label, value); - } - }; - - // Track Pages By Id - addPage = (acc, arr) => { - let [id, page] = arr; - - const info = page.info; - - if (info.id) id = info.id; - else page.info.id = id; // update id - - const pages = info.pages; - - // NOTE: This is not true for nested pages with more info... - if (page instanceof HTMLElement) acc[id] = page; - - if (pages) { - const pagesArr = Object.values(pages); - - const originalNext = page.info.next; - page.info.next = pagesArr[0]; // Next is the first nested page - - // Update info with relative information - Object.entries(pages).forEach(([newId, nestedPage], i) => { - nestedPage.info.base = id; - - const previousPage = pagesArr[i - 1]; - nestedPage.info.previous = - (previousPage?.info?.pages - ? Object.values(previousPage.info.pages).pop() - : previousPage) ?? page; // Previous is the previous nested page or the parent page - nestedPage.info.next = pagesArr[i + 1] ?? originalNext; // Next is the next nested page or the original next page - nestedPage.info.id = `${id}/${newId}`; - nestedPage.info.parent = page; - }); - - // Register all pages - Object.entries(pages).forEach((arr) => this.addPage(acc, arr)); - } - - return acc; - }; - - #first = true; - updated() { - if (this.#first) { - this.#first = false; - this.#updated(); - } - } - - render() { - this.style.width = "100%"; - this.style.height = "100%"; - this.style.display = "grid"; - this.style.gridTemplateColumns = "fit-content(0px) 1fr"; - this.style.position = "relative"; - this.main.style.height = "100vh"; - - if (this.name) this.sidebar.name = this.name; - if (this.logo) this.sidebar.logo = this.logo; - if ("renderNameInSidebar" in this) - this.sidebar.renderName = this.renderNameInSidebar; - - return html` -
${this.sidebar} ${this.subSidebar}
- ${this.main} - `; - } -} - -customElements.get("nwb-dashboard") || - customElements.define("nwb-dashboard", Dashboard); +import { LitElement, html } from "lit"; +import useGlobalStyles from "./utils/useGlobalStyles.js"; + +import { Main, checkIfPageIsSkipped } from "./Main.js"; +import { Sidebar } from "./sidebar.js"; +import { NavigationSidebar } from "./NavigationSidebar.js"; + +// Defined by Garrett late in GUIDE development to clearly separate global styles unrelated to SODA (May 20th, 2024) +import "../../assets/css/custom.css"; + +// Global styles to apply with the dashboard +import "../../assets/css/variables.css"; +import "../../assets/css/nativize.css"; +import "../../assets/css/global.css"; +import "../../assets/css/nav.css"; +import "../../assets/css/section.css"; +import "../../assets/css/demo.css"; +import "../../assets/css/individualtab.css"; +import "../../assets/css/main_tabs.css"; +// import "../../node_modules/cropperjs/dist/cropper.css" +import "../../../../../node_modules/notyf/notyf.min.css"; +import "../../assets/css/spur.css"; +import "../../assets/css/main.css"; +// import "https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" +import "../../../../../node_modules/@fortawesome/fontawesome-free/css/all.css"; +// import "../../node_modules/select2/dist/css/select2.min.css" +// import "../../node_modules/@toast-ui/editor/dist/toastui-editor.css" +// import "../../node_modules/codemirror/lib/codemirror.css" +// import "../../node_modules/@yaireo/tagify/dist/tagify.css" +import "../../../../../node_modules/fomantic-ui/dist/semantic.min.css"; +import "../../../../../node_modules/fomantic-ui/dist/components/accordion.min.css"; +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 { 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 }, + name: { type: String, reflect: true }, + logo: { type: String, reflect: true }, + activePage: { type: String, reflect: true }, + globalState: { type: Object, reflect: true }, + }; + } + + main; + sidebar; + subSidebar; + + // Custom Getter / Setter for Subtitle + #subtitle; + set subtitle(v) { + this.#subtitle = v; + this.sidebar.subtitle = v; + } + + get subtitle() { + return this.#subtitle; + } + + pagesById = {}; + page; + + next = () => this.main.next(); + back = () => this.main.back(); + + constructor(props = {}) { + super(); + + this.main = new Main(); + this.main.classList.add("dash-app"); + + this.sidebar = new Sidebar(); + this.sidebar.onClick = (_, value) => { + const id = value.info.id; + if (this.page) this.page.to(id); + else this.setAttribute("activePage", id); + }; + + this.subSidebar = new NavigationSidebar(); + this.subSidebar.onClick = async (id) => this.page.to(id); + + this.pages = props.pages ?? {}; + this.name = props.name; + this.logo = props.logo; + this.renderNameInSidebar = props.renderNameInSidebar ?? true; + + this.globalState = props.globalState; // Impose a static global state on pages that have none + + if (props.activePage) this.setAttribute("activePage", props.activePage); + + // Handle all pop and push state updates + const pushState = window.history.pushState; + + const pushPopListener = (popEvent) => { + if (popEvent.state) { + const titleString = popEvent.state.title ?? popEvent.state.label; + document.title = `${titleString} - ${this.name}`; + const page = this.pagesById[popEvent.state.page]; // ?? this.pagesById[this.#activatePage] + if (!page) return; + if (page === this.page) return; // Do not rerender current page + this.setMain(page); + } + }; + + window.history.pushState = function (state) { + pushPopListener({ state: state }); + return pushState.apply(window.history, arguments); + }; + + window.addEventListener("popstate", pushPopListener); + window.addEventListener("pushstate", pushPopListener); + + this.#updated(); + } + + requestPageUpdate() { + if (this.page) this.page.requestUpdate(); + } + + createRenderRoot() { + return this; + } + + attributeChangedCallback(key, _, latest) { + super.attributeChangedCallback(...arguments); + if (this.sidebar && (key === "name" || key === "logo")) this.sidebar[key] = latest; + else if (key === "renderNameInSidebar") this.sidebar.renderName = latest === "true" || latest === true; + else if (key === "pages") this.#updated(latest); + else if (key.toLowerCase() === "activepage") { + if (this.page && this.page.info.parent && this.page.info.section) { + const currentProject = getCurrentProjectName(); + if (currentProject) updateAppProgress(latest, currentProject); + } + + while (latest && !this.pagesById[latest]) latest = latest.split("/").slice(0, -1).join("/"); // Trim off last character until you find a page + + // Update sidebar states + + this.sidebar.selectItem(latest); // Just highlight the item + this.sidebar.initialize = false; + this.#activatePage(latest); + return; + } else if (key.toLowerCase() === "globalstate" && this.page) { + this.page.info.globalState = JSON.parse(latest); + this.page.requestUpdate(); + } + } + + getPage(entry) { + if (!entry) return reloadPageToHome(); + const page = entry.page ?? entry; + if (page instanceof HTMLElement) return page; + else if (typeof page === "object") return this.getPage(Object.values(page)[0]); + } + + updateSections({ sidebar = true, main = false } = {}, globalState = this.page.info.globalState) { + const info = this.page.info; + let parent = info.parent; + + if (sidebar) { + this.subSidebar.sections = this.#getSections(parent.info.pages, globalState); // Update sidebar items (if changed) + } + + const { sections } = this.subSidebar; + + if (main) { + if (this.page.header) delete this.page.header.sections; // Ensure sections are updated + this.main.set({ + page: this.page, + sections, + }); + } + + return sections; + } + + setMain(page) { + window.getSelection().empty(); // Remove user selection before transitioning + + // Update Previous Page + const info = page.info; + const previous = this.page; + + // if (previous === page) return // Prevent rerendering the same page + + const isNested = info.parent && info.section; + + const toPass = {}; + if (previous) { + previous.dismiss(); // Dismiss all notifications for this page + if (previous.info.globalState) toPass.globalState = previous.info.globalState; // Pass global state over if appropriate + previous.active = false; + } + + // On initial reload, load global state if you can + if (isNested && !("globalState" in toPass)) toPass.globalState = this.globalState ?? page.load(); + + // Update Active Page + this.page = page; + + // Reset global state if page has no parent + if (!this.page.info.parent) toPass.globalState = {}; + + if (isNested) { + let parent = info.parent; + while (parent.info.parent) parent = parent.info.parent; // Lock sections to the top-level parent + this.updateSections({ sidebar: true }, toPass.globalState); + this.subSidebar.active = info.id; // Update active item (if changed) + this.sidebar.hide(true); + this.subSidebar.show(); + } else { + this.sidebar.show(); + this.subSidebar.hide(); + } + + this.page.set(toPass, false); + + this.page.checkSyncState().then(async () => { + const projectName = info.globalState?.project?.name; + + this.subSidebar.header = projectName + ? `

${projectName}

Conversion Pipeline` + : projectName; + + this.updateSections({ sidebar: false, main: true }); + + if (this.#transitionPromise.value) this.#transitionPromise.trigger(page); // This ensures calls to page.to() can be properly awaited until the next page is ready + + const { skipped } = this.subSidebar.sections[info.section]?.pages?.[info.id] ?? {}; + + if (skipped) { + if (isStorybook) return; // Do not skip on storybook + + // Run skip functions + Object.entries(page.workflow).forEach(([key, state]) => { + if (typeof state.skip === "function") state.skip(); + }); + + // Skip right over the page if configured as such + if (previous && previous.info.previous === this.page) await this.page.onTransition(-1); + else await this.page.onTransition(1); + } + }); + } + + // Populate the sections tracked for this page by using the global state as a model + #getSections = (pages = {}, globalState = {}) => { + if (!globalState.sections) globalState.sections = {}; + + Object.entries(pages).forEach(([id, page]) => { + const info = page.info; + if (info.id) id = info.id; + + if (info.section) { + const section = info.section; + + let state = globalState.sections[section]; + if (!state) + state = globalState.sections[section] = { + open: false, + active: false, + pages: {}, + }; + + let pageState = state.pages[id]; + if (!pageState) + pageState = state.pages[id] = { + visited: false, + active: false, + saved: false, + pageLabel: page.info.label, + pageTitle: page.info.title, + }; + + info.states = pageState; + + state.active = false; + pageState.active = false; + + // Check if page is skipped based on workflow state (if applicable) + pageState.skipped = checkIfPageIsSkipped(page, globalState.project?.workflow); + + if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states + + if (!("visited" in pageState)) pageState.visited = false; + if (id === this.page.info.id) state.active = pageState.visited = pageState.active = true; // Set active page as visited + } + }); + + return (globalState.sections = { ...globalState.sections }); // Update global state with new reference (to ensure re-render) + }; + + #transitionPromise = {}; + + #updated(pages = this.pages) { + const url = new URL(window.location.href); + let active = url.pathname.slice(1); + if (isElectron || isStorybook) active = new URLSearchParams(url.search).get("page"); + if (!active) active = this.activePage; // default to active page + + this.main.onTransition = async (transition) => { + const promise = (this.#transitionPromise.value = new Promise( + (resolve) => (this.#transitionPromise.trigger = resolve) + )); + + if (typeof transition === "number") { + const info = this.page.info; + const sign = Math.sign(transition); + if (sign === 1) transition = info.next.info.id; + else if (sign === -1) transition = (info.previous ?? info.parent).info.id; // Default to back in time + } + + this.setAttribute("activePage", transition); + + return promise; + }; + + this.main.updatePages = () => { + this.#updated(); // Rerender with new pages + this.setAttribute("activePage", this.page.info.id); // Re-render the current page + }; + + this.pagesById = {}; + Object.entries(pages).forEach((arr) => this.addPage(this.pagesById, arr)); + this.sidebar.pages = pages; + + if (active) this.setAttribute("activePage", active); + } + + #activatePage = (id) => { + const page = this.getPage(this.pagesById[id]); + + if (page) { + const { id, label } = page.info; + const queries = new URLSearchParams(window.location.search); + queries.set("page", id); + const project = queries.get("project"); + const value = + isElectron || isStorybook + ? `?${queries}` + : `${window.location.origin}/${id === "/" ? "" : id}?${queries}`; + history.pushState({ page: id, label, project }, label, value); + } + }; + + // Track Pages By Id + addPage = (acc, arr) => { + let [id, page] = arr; + + const info = page.info; + + if (info.id) id = info.id; + else page.info.id = id; // update id + + const pages = info.pages; + + // NOTE: This is not true for nested pages with more info... + if (page instanceof HTMLElement) acc[id] = page; + + if (pages) { + const pagesArr = Object.values(pages); + + const originalNext = page.info.next; + page.info.next = pagesArr[0]; // Next is the first nested page + + // Update info with relative information + Object.entries(pages).forEach(([newId, nestedPage], i) => { + nestedPage.info.base = id; + + const previousPage = pagesArr[i - 1]; + nestedPage.info.previous = + (previousPage?.info?.pages ? Object.values(previousPage.info.pages).pop() : previousPage) ?? page; // Previous is the previous nested page or the parent page + nestedPage.info.next = pagesArr[i + 1] ?? originalNext; // Next is the next nested page or the original next page + nestedPage.info.id = `${id}/${newId}`; + nestedPage.info.parent = page; + }); + + // Register all pages + Object.entries(pages).forEach((arr) => this.addPage(acc, arr)); + } + + return acc; + }; + + #first = true; + updated() { + if (this.#first) { + this.#first = false; + this.#updated(); + } + } + + render() { + this.style.width = "100%"; + this.style.height = "100%"; + this.style.display = "grid"; + this.style.gridTemplateColumns = "fit-content(0px) 1fr"; + this.style.position = "relative"; + this.main.style.height = "100vh"; + + if (this.name) this.sidebar.name = this.name; + if (this.logo) this.sidebar.logo = this.logo; + if ("renderNameInSidebar" in this) this.sidebar.renderName = this.renderNameInSidebar; + + return html` +
${this.sidebar} ${this.subSidebar}
+ ${this.main} + `; + } +} + +customElements.get("nwb-dashboard") || customElements.define("nwb-dashboard", Dashboard); diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 7f5eb3a51d..8d128b1ae8 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,480 +1,438 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; - -import { notyf } from "../../../dependencies.js"; -import { homeDirectory, testDataFolderPath } from "../../../globals.js"; - -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, -} from "../../../../utils/electron.js"; - -import saveSVG from "../../../../assets/icons/save.svg?raw"; -import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; -import deleteSVG from "../../../../assets/icons/delete.svg?raw"; -import generateSVG from "../../../../assets/icons/restart.svg?raw"; -import downloadSVG from "../../../../assets/download.svg?raw"; -import infoSVG from "../../../../assets/info.svg?raw"; - -import { header } from "../../forms/utils"; - -import examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals"; -import { Modal } from "../../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath( - testDataFolderPath, - "multi_session_dataset", -); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => - fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone( - info.interfaces ? info.metadata ?? {} : {}, - ); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - }, -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) - return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "data/generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "data/generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify( - `Test dataset successfully generated at ${sanitizedOutputPath}!`, - ); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) - return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) - return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error", - ); - - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce( - (acc, v) => (acc += v === true ? 1 : 0), - 0, - ); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning", - ); - } else if (nSuccessful) - this.#openNotyf( - `Generated ${nSuccessful} test pipelines.`, - "success", - ); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error", - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement([ - "developer", - "testing_data_folder", - ]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && - fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify( - `Test dataset successfully deleted from your system.`, - ); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send( - "showItemInFolder", - DATASET_OUTPUT_PATH, - ); - else { - this.notify( - "The test dataset no longer exists!", - "warning", - ); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send( - "showItemInFolder", - output_path, - ); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || - customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; + +import saveSVG from "../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../assets/icons/delete.svg?raw"; +import generateSVG from "../../../../assets/icons/restart.svg?raw"; +import downloadSVG from "../../../../assets/download.svg?raw"; +import infoSVG from "../../../../assets/info.svg?raw"; + +import { header } from "../../forms/utils"; + +import examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + } +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error" + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning" + ); + } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error" + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify(`Test dataset successfully deleted from your system.`); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); + else { + this.notify("The test dataset no longer exists!", "warning"); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send("showItemInFolder", output_path); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); diff --git a/src/electron/frontend/core/pages.js b/src/electron/frontend/core/pages.js index 7ba96d356a..11f042adcf 100644 --- a/src/electron/frontend/core/pages.js +++ b/src/electron/frontend/core/pages.js @@ -1,194 +1,194 @@ -import { DocumentationPage } from "./components/pages/documentation/Documentation"; -import { ContactPage } from "./components/pages/contact-us/Contact"; -import { GuidedHomePage } from "./components/pages/guided-mode/GuidedHome"; -import { GuidedNewDatasetPage } from "./components/pages/guided-mode/setup/GuidedNewDatasetInfo"; -import { GuidedStructurePage } from "./components/pages/guided-mode/data/GuidedStructure"; -import { sections } from "./components/pages/globals"; -import { GuidedSubjectsPage } from "./components/pages/guided-mode/setup/GuidedSubjects"; -import { GuidedSourceDataPage } from "./components/pages/guided-mode/data/GuidedSourceData"; -import { GuidedMetadataPage } from "./components/pages/guided-mode/data/GuidedMetadata"; -import { GuidedUploadPage } from "./components/pages/guided-mode/options/GuidedUpload"; -import { GuidedResultsPage } from "./components/pages/guided-mode/results/GuidedResults"; -import { Dashboard } from "./components/Dashboard"; -import { GuidedStubPreviewPage } from "./components/pages/guided-mode/options/GuidedStubPreview"; -import { GuidedInspectorPage } from "./components/pages/guided-mode/options/GuidedInspectorPage"; - -import logo from "../assets/img/logo-guide-draft-transparent-tight.png"; -import { GuidedPathExpansionPage } from "./components/pages/guided-mode/data/GuidedPathExpansion"; -import uploadIcon from "../assets/icons/dandi.svg?raw"; -import inspectIcon from "../assets/icons/inspect.svg?raw"; -import neurosiftIcon from "../assets/icons/neurosift-logo.svg?raw"; - -import settingsIcon from "../assets/icons/settings.svg?raw"; - -import { UploadsPage } from "./components/pages/uploads/UploadsPage"; -import { SettingsPage } from "./components/pages/settings/SettingsPage"; -import { InspectPage } from "./components/pages/inspect/InspectPage"; -import { PreviewPage } from "./components/pages/preview/PreviewPage"; -import { GuidedPreform } from "./components/pages/guided-mode/setup/Preform"; -import { GuidedDandiResultsPage } from "./components/pages/guided-mode/results/GuidedDandiResults"; - -let dashboard = document.querySelector("nwb-dashboard"); -if (!dashboard) dashboard = new Dashboard(); -dashboard.logo = logo; -dashboard.name = "NWB GUIDE"; -dashboard.renderNameInSidebar = false; - -const resourcesGroup = "Resources"; - -const guidedIcon = ` - - - - -`; - -const documentationIcon = ` - - -`; - -const contactIcon = ` - -`; - -const pages = { - "/": new GuidedHomePage({ - label: "Convert", - icon: guidedIcon, - pages: { - details: new GuidedNewDatasetPage({ - title: "Project Setup", - label: "Project details", - section: sections[0], - }), - - workflow: new GuidedPreform({ - title: "Pipeline Workflow", - label: "Pipeline workflow", - section: sections[0], - }), - - structure: new GuidedStructurePage({ - title: "Provide Data Formats", - label: "Data formats", - section: sections[0], - }), - - locate: new GuidedPathExpansionPage({ - title: "Locate Data", - label: "Locate data", - section: sections[0], - }), - - subjects: new GuidedSubjectsPage({ - title: "Subject Metadata", - label: "Subject details", - section: sections[0], - }), - - sourcedata: new GuidedSourceDataPage({ - title: "Source Data Information", - label: "Source data", - section: sections[1], - }), - - metadata: new GuidedMetadataPage({ - title: "File Metadata", - label: "File metadata", - section: sections[1], - }), - - inspect: new GuidedInspectorPage({ - title: "Inspector Report", - label: "Validate metadata", - section: sections[2], - sync: ["preview"], - }), - - preview: new GuidedStubPreviewPage({ - title: "Conversion Preview", - label: "Preview NWB files", - section: sections[2], - sync: ["preview"], - }), - - conversion: new GuidedResultsPage({ - title: "Conversion Review", - label: "Review conversion", - section: sections[2], - sync: ["conversion"], - }), - - upload: new GuidedUploadPage({ - title: "DANDI Upload", - label: "Upload to DANDI", - section: sections[3], - sync: ["conversion"], - }), - - review: new GuidedDandiResultsPage({ - title: "Upload Review", - label: "Review published data", - section: sections[3], - }), - }, - }), - validate: new InspectPage({ - label: "Validate", - icon: inspectIcon, - }), - explore: new PreviewPage({ - label: "Explore", - icon: neurosiftIcon, - }), - uploads: new UploadsPage({ - label: "Upload", - icon: uploadIcon, - }), - docs: new DocumentationPage({ - label: "Documentation", - icon: documentationIcon, - group: resourcesGroup, - }), - contact: new ContactPage({ - label: "Contact Us", - icon: contactIcon, - group: resourcesGroup, - }), - settings: new SettingsPage({ - label: "Settings", - icon: settingsIcon, - group: "bottom", - }), -}; - -dashboard.pages = pages; - -export { dashboard }; +import { DocumentationPage } from "./components/pages/documentation/Documentation"; +import { ContactPage } from "./components/pages/contact-us/Contact"; +import { GuidedHomePage } from "./components/pages/guided-mode/GuidedHome"; +import { GuidedNewDatasetPage } from "./components/pages/guided-mode/setup/GuidedNewDatasetInfo"; +import { GuidedStructurePage } from "./components/pages/guided-mode/data/GuidedStructure"; +import { sections } from "./components/pages/globals"; +import { GuidedSubjectsPage } from "./components/pages/guided-mode/setup/GuidedSubjects"; +import { GuidedSourceDataPage } from "./components/pages/guided-mode/data/GuidedSourceData"; +import { GuidedMetadataPage } from "./components/pages/guided-mode/data/GuidedMetadata"; +import { GuidedUploadPage } from "./components/pages/guided-mode/options/GuidedUpload"; +import { GuidedResultsPage } from "./components/pages/guided-mode/results/GuidedResults"; +import { Dashboard } from "./components/Dashboard"; +import { GuidedStubPreviewPage } from "./components/pages/guided-mode/options/GuidedStubPreview"; +import { GuidedInspectorPage } from "./components/pages/guided-mode/options/GuidedInspectorPage"; + +import logo from "../assets/img/logo-guide-draft-transparent-tight.png"; +import { GuidedPathExpansionPage } from "./components/pages/guided-mode/data/GuidedPathExpansion"; +import uploadIcon from "../assets/icons/dandi.svg?raw"; +import inspectIcon from "../assets/icons/inspect.svg?raw"; +import neurosiftIcon from "../assets/icons/neurosift-logo.svg?raw"; + +import settingsIcon from "../assets/icons/settings.svg?raw"; + +import { UploadsPage } from "./components/pages/uploads/UploadsPage"; +import { SettingsPage } from "./components/pages/settings/SettingsPage"; +import { InspectPage } from "./components/pages/inspect/InspectPage"; +import { PreviewPage } from "./components/pages/preview/PreviewPage"; +import { GuidedPreform } from "./components/pages/guided-mode/setup/Preform"; +import { GuidedDandiResultsPage } from "./components/pages/guided-mode/results/GuidedDandiResults"; + +let dashboard = document.querySelector("nwb-dashboard"); +if (!dashboard) dashboard = new Dashboard(); +dashboard.logo = logo; +dashboard.name = "NWB GUIDE"; +dashboard.renderNameInSidebar = false; + +const resourcesGroup = "Resources"; + +const guidedIcon = ` + + + + +`; + +const documentationIcon = ` + + +`; + +const contactIcon = ` + +`; + +const pages = { + "/": new GuidedHomePage({ + label: "Convert", + icon: guidedIcon, + pages: { + details: new GuidedNewDatasetPage({ + title: "Project Setup", + label: "Project details", + section: sections[0], + }), + + workflow: new GuidedPreform({ + title: "Pipeline Workflow", + label: "Pipeline workflow", + section: sections[0], + }), + + structure: new GuidedStructurePage({ + title: "Provide Data Formats", + label: "Data formats", + section: sections[0], + }), + + locate: new GuidedPathExpansionPage({ + title: "Locate Data", + label: "Locate data", + section: sections[0], + }), + + subjects: new GuidedSubjectsPage({ + title: "Subject Metadata", + label: "Subject details", + section: sections[0], + }), + + sourcedata: new GuidedSourceDataPage({ + title: "Source Data Information", + label: "Source data", + section: sections[1], + }), + + metadata: new GuidedMetadataPage({ + title: "File Metadata", + label: "File metadata", + section: sections[1], + }), + + inspect: new GuidedInspectorPage({ + title: "Inspector Report", + label: "Validate metadata", + section: sections[2], + sync: ["preview"], + }), + + preview: new GuidedStubPreviewPage({ + title: "Conversion Preview", + label: "Preview NWB files", + section: sections[2], + sync: ["preview"], + }), + + conversion: new GuidedResultsPage({ + title: "Conversion Review", + label: "Review conversion", + section: sections[2], + sync: ["conversion"], + }), + + upload: new GuidedUploadPage({ + title: "DANDI Upload", + label: "Upload to DANDI", + section: sections[3], + sync: ["conversion"], + }), + + review: new GuidedDandiResultsPage({ + title: "Upload Review", + label: "Review published data", + section: sections[3], + }), + }, + }), + validate: new InspectPage({ + label: "Validate", + icon: inspectIcon, + }), + explore: new PreviewPage({ + label: "Explore", + icon: neurosiftIcon, + }), + uploads: new UploadsPage({ + label: "Upload", + icon: uploadIcon, + }), + docs: new DocumentationPage({ + label: "Documentation", + icon: documentationIcon, + group: resourcesGroup, + }), + contact: new ContactPage({ + label: "Contact Us", + icon: contactIcon, + group: resourcesGroup, + }), + settings: new SettingsPage({ + label: "Settings", + icon: settingsIcon, + group: "bottom", + }), +}; + +dashboard.pages = pages; + +export { dashboard }; diff --git a/src/electron/frontend/utils/electron.js b/src/electron/frontend/utils/electron.js index 88a8155e85..e8c6f2e4a0 100644 --- a/src/electron/frontend/utils/electron.js +++ b/src/electron/frontend/utils/electron.js @@ -1,98 +1,86 @@ -import { updateURLParams } from "./url.js"; - -var userAgent = navigator.userAgent.toLowerCase(); -export const isElectron = userAgent.indexOf(" electron/") > -1; - -export let port = 4242; -export let SERVER_FILE_PATH = ""; -export const electron = globalThis.electron ?? {}; // ipcRenderer, remote, shell, etc. -export let fs = null; -export let os = null; -export let remote = {}; -export let app = null; -export let path = null; -export let log = null; -export let crypto = null; - -let updateAvailable = false; -const updateAvailableCallbacks = []; -export const onUpdateAvailable = (callback) => { - if (updateAvailable) callback(updateAvailable); - else updateAvailableCallbacks.push(callback); -}; - -let updateProgress = null; - -const updateProgressCallbacks = []; -export const onUpdateProgress = (callback) => { - if (updateProgress) callback(updateProgress); - else updateProgressCallbacks.push(callback); -}; - -export const registerUpdateProgress = (info) => { - updateProgress = info; - updateProgressCallbacks.forEach((cb) => cb(info)); -}; - -const registerUpdate = (info) => { - updateAvailable = info; - document.body.setAttribute("data-update-available", JSON.stringify(info)); - updateAvailableCallbacks.forEach((cb) => cb(info)); -}; - -// Used in tests -try { - crypto = require("crypto"); -} catch {} - -if (isElectron) { - try { - fs = require("fs-extra"); // File System - os = require("os"); - crypto = require("crypto"); - remote = require("@electron/remote"); - app = remote.app; - - electron.ipcRenderer.on("fileOpened", (info, filepath) => { - updateURLParams({ file: filepath }); - const dashboard = document.querySelector("nwb-dashboard"); - const activePage = dashboard.getAttribute("activePage"); - if (activePage === "preview") dashboard.requestUpdate(); - else dashboard.setAttribute("activePage", "preview"); - }); - - ["log", "warn", "error"].forEach((method) => - electron.ipcRenderer.on(`console.${method}`, (_, ...args) => - console[method](`[main-process]:`, ...args), - ), - ); - - electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => - console.log(`[Update]:`, ...args), - ); - - electron.ipcRenderer.on(`update-available`, (_, info) => - info ? registerUpdate(info) : "", - ); - - electron.ipcRenderer.on(`update-progress`, (_, info) => - registerUpdateProgress(info), - ); - electron.ipcRenderer.on(`update-complete`, (_, ...args) => - console.log(`[Update]:`, ...args), - ); - - electron.ipcRenderer.on(`update-error`, (_, ...args) => - console.log(`[Update]:`, ...args), - ); - - port = electron.ipcRenderer.sendSync("get-port"); - console.log("User OS:", os.type(), os.platform(), "version:", os.release()); - - SERVER_FILE_PATH = electron.ipcRenderer.sendSync("get-server-file-path"); - - path = require("path"); - } catch (error) { - console.error("Electron API access failed —", error); - } -} else console.warn("Electron API is blocked for web builds"); +import { updateURLParams } from "./url.js"; + +var userAgent = navigator.userAgent.toLowerCase(); +export const isElectron = userAgent.indexOf(" electron/") > -1; + +export let port = 4242; +export let SERVER_FILE_PATH = ""; +export const electron = globalThis.electron ?? {}; // ipcRenderer, remote, shell, etc. +export let fs = null; +export let os = null; +export let remote = {}; +export let app = null; +export let path = null; +export let log = null; +export let crypto = null; + +let updateAvailable = false; +const updateAvailableCallbacks = []; +export const onUpdateAvailable = (callback) => { + if (updateAvailable) callback(updateAvailable); + else updateAvailableCallbacks.push(callback); +}; + +let updateProgress = null; + +const updateProgressCallbacks = []; +export const onUpdateProgress = (callback) => { + if (updateProgress) callback(updateProgress); + else updateProgressCallbacks.push(callback); +}; + +export const registerUpdateProgress = (info) => { + updateProgress = info; + updateProgressCallbacks.forEach((cb) => cb(info)); +}; + +const registerUpdate = (info) => { + updateAvailable = info; + document.body.setAttribute("data-update-available", JSON.stringify(info)); + updateAvailableCallbacks.forEach((cb) => cb(info)); +}; + +// Used in tests +try { + crypto = require("crypto"); +} catch {} + +if (isElectron) { + try { + fs = require("fs-extra"); // File System + os = require("os"); + crypto = require("crypto"); + remote = require("@electron/remote"); + app = remote.app; + + electron.ipcRenderer.on("fileOpened", (info, filepath) => { + updateURLParams({ file: filepath }); + const dashboard = document.querySelector("nwb-dashboard"); + const activePage = dashboard.getAttribute("activePage"); + if (activePage === "preview") dashboard.requestUpdate(); + else dashboard.setAttribute("activePage", "preview"); + }); + + ["log", "warn", "error"].forEach((method) => + electron.ipcRenderer.on(`console.${method}`, (_, ...args) => console[method](`[main-process]:`, ...args)) + ); + + electron.ipcRenderer.on(`checking-for-update`, (_, ...args) => console.log(`[Update]:`, ...args)); + + electron.ipcRenderer.on(`update-available`, (_, info) => (info ? registerUpdate(info) : "")); + + electron.ipcRenderer.on(`update-progress`, (_, info) => registerUpdateProgress(info)); + electron.ipcRenderer.on(`update-complete`, (_, ...args) => console.log(`[Update]:`, ...args)); + + electron.ipcRenderer.on(`update-error`, (_, ...args) => console.log(`[Update]:`, ...args)); + + port = electron.ipcRenderer.sendSync("get-port"); + console.log("User OS:", os.type(), os.platform(), "version:", os.release()); + + SERVER_FILE_PATH = electron.ipcRenderer.sendSync("get-server-file-path"); + + path = require("path"); + } catch (error) { + console.error("Electron API access failed —", error); + } +} else console.warn("Electron API is blocked for web builds"); From ea768a97a81da33cfd6e2ab20e5b24dc6c5ac8ea Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Wed, 29 May 2024 14:53:56 -0400 Subject: [PATCH 08/18] remove getting started page; correct relative path to assets --- .../pages/getting-started/GettingStarted.js | 87 -- .../components/pages/settings/SettingsPage.js | 918 +++++++++--------- 2 files changed, 480 insertions(+), 525 deletions(-) delete mode 100644 src/electron/frontend/core/components/pages/getting-started/GettingStarted.js diff --git a/src/electron/frontend/core/components/pages/getting-started/GettingStarted.js b/src/electron/frontend/core/components/pages/getting-started/GettingStarted.js deleted file mode 100644 index 2a25d8200f..0000000000 --- a/src/electron/frontend/core/components/pages/getting-started/GettingStarted.js +++ /dev/null @@ -1,87 +0,0 @@ -import { html } from "lit"; -import { column1Lottie, column2Lottie, column3Lottie } from "../../../../assets/lotties/overview-lotties.js"; -import { Page } from "../Page.js"; - -import { startLottie } from "../../../dependencies/globals"; - -export class GettingStartedPage extends Page { - constructor(...args) { - super(...args); - } - - updated() { - // this.content = (this.shadowRoot ?? this).querySelector("#content"); - let column1 = this.query("#lottie1"); - let column2 = this.query("#lottie2"); - let column3 = this.query("#lottie3"); - startLottie(column1, column1Lottie); - startLottie(column2, column2Lottie); - startLottie(column3, column3Lottie); - } - - render() { - return html` -
-

Your one-stop tool for converting and uploading NWB datasets to the DANDI Archive!

- -
-
-

Curate

- -
-

- Rapidly prepare your data and metadata according to the NWB Best Practices and DANDI - requirements -

-
-
-

Share

-
-

Easily upload your curated dataset to the DANDI archive

-
-
-

Relax

-
-

Use our intuitive interface and automations to streamline your process

-
-
-
- - - -
-
- `; - } -} - -customElements.get("nwbguide-start-page") || customElements.define("nwbguide-start-page", GettingStartedPage); diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 8d128b1ae8..d7aaf83255 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,438 +1,480 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; - -import { notyf } from "../../../dependencies.js"; -import { homeDirectory, testDataFolderPath } from "../../../globals.js"; - -import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; - -import saveSVG from "../../../../assets/icons/save.svg?raw"; -import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; -import deleteSVG from "../../../../assets/icons/delete.svg?raw"; -import generateSVG from "../../../../assets/icons/restart.svg?raw"; -import downloadSVG from "../../../../assets/download.svg?raw"; -import infoSVG from "../../../../assets/info.svg?raw"; - -import { header } from "../../forms/utils"; - -import examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals"; -import { Modal } from "../../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - } -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "data/generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - } - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "data/generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - } - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error" - ); - - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning" - ); - } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error" - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify(`Test dataset successfully deleted from your system.`); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); - else { - this.notify("The test dataset no longer exists!", "warning"); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send("showItemInFolder", output_path); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, +} from "../../../../utils/electron.js"; + +import saveSVG from "../../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../../assets/icons/delete.svg?raw"; +import generateSVG from "../../../../../assets/icons/restart.svg?raw"; +import downloadSVG from "../../../../../assets/download.svg?raw"; +import infoSVG from "../../../../../assets/info.svg?raw"; + +import { header } from "../../forms/utils"; + +import examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath( + testDataFolderPath, + "multi_session_dataset", +); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => + fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone( + info.interfaces ? info.metadata ?? {} : {}, + ); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + }, +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) + return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + }, + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + }, + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify( + `Test dataset successfully generated at ${sanitizedOutputPath}!`, + ); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) + return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) + return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error", + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce( + (acc, v) => (acc += v === true ? 1 : 0), + 0, + ); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning", + ); + } else if (nSuccessful) + this.#openNotyf( + `Generated ${nSuccessful} test pipelines.`, + "success", + ); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error", + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement([ + "developer", + "testing_data_folder", + ]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && + fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify( + `Test dataset successfully deleted from your system.`, + ); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send( + "showItemInFolder", + DATASET_OUTPUT_PATH, + ); + else { + this.notify( + "The test dataset no longer exists!", + "warning", + ); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send( + "showItemInFolder", + output_path, + ); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || + customElements.define("nwbguide-settings-page", SettingsPage); From 6e2c586abe56638170b76338de8c30d29f50a3da Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 18:54:22 +0000 Subject: [PATCH 09/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../components/pages/settings/SettingsPage.js | 918 +++++++++--------- 1 file changed, 438 insertions(+), 480 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index d7aaf83255..baf7336a7b 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,480 +1,438 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; - -import { notyf } from "../../../dependencies.js"; -import { homeDirectory, testDataFolderPath } from "../../../globals.js"; - -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, -} from "../../../../utils/electron.js"; - -import saveSVG from "../../../../../assets/icons/save.svg?raw"; -import folderSVG from "../../../../../assets/icons/folder_open.svg?raw"; -import deleteSVG from "../../../../../assets/icons/delete.svg?raw"; -import generateSVG from "../../../../../assets/icons/restart.svg?raw"; -import downloadSVG from "../../../../../assets/download.svg?raw"; -import infoSVG from "../../../../../assets/info.svg?raw"; - -import { header } from "../../forms/utils"; - -import examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals"; -import { Modal } from "../../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath( - testDataFolderPath, - "multi_session_dataset", -); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => - fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone( - info.interfaces ? info.metadata ?? {} : {}, - ); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - }, -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) - return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "data/generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "data/generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify( - `Test dataset successfully generated at ${sanitizedOutputPath}!`, - ); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) - return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) - return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error", - ); - - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce( - (acc, v) => (acc += v === true ? 1 : 0), - 0, - ); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning", - ); - } else if (nSuccessful) - this.#openNotyf( - `Generated ${nSuccessful} test pipelines.`, - "success", - ); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error", - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement([ - "developer", - "testing_data_folder", - ]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && - fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify( - `Test dataset successfully deleted from your system.`, - ); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send( - "showItemInFolder", - DATASET_OUTPUT_PATH, - ); - else { - this.notify( - "The test dataset no longer exists!", - "warning", - ); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send( - "showItemInFolder", - output_path, - ); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || - customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; + +import saveSVG from "../../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../../assets/icons/delete.svg?raw"; +import generateSVG from "../../../../../assets/icons/restart.svg?raw"; +import downloadSVG from "../../../../../assets/download.svg?raw"; +import infoSVG from "../../../../../assets/info.svg?raw"; + +import { header } from "../../forms/utils"; + +import examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + } +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error" + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning" + ); + } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error" + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify(`Test dataset successfully deleted from your system.`); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); + else { + this.notify("The test dataset no longer exists!", "warning"); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send("showItemInFolder", output_path); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); From c52f4d6ca8590983f1050250acc9f898c31f44ed Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Wed, 29 May 2024 14:56:57 -0400 Subject: [PATCH 10/18] swap to base 10 --- src/electron/frontend/core/components/ProgressBar.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/electron/frontend/core/components/ProgressBar.ts b/src/electron/frontend/core/components/ProgressBar.ts index 5519bbf269..bb30bbdbd7 100644 --- a/src/electron/frontend/core/components/ProgressBar.ts +++ b/src/electron/frontend/core/components/ProgressBar.ts @@ -26,8 +26,8 @@ export function humanReadableBytes(size: number | string) { size = parseFloat(size); // Loop until the size is less than 1024 and increment the unit - while (size >= 1024 && index < units.length - 1) { - size /= 1024; + while (size >= 1000 && index < units.length - 1) { + size /= 1000; index += 1; } From c45b055c7b84c9d3c5d7b7dda990bd1f7a3958d7 Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Wed, 29 May 2024 15:02:59 -0400 Subject: [PATCH 11/18] fix relative paths again --- .../components/pages/settings/SettingsPage.js | 918 +++++++++--------- 1 file changed, 480 insertions(+), 438 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index baf7336a7b..f63c09690d 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,438 +1,480 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; - -import { notyf } from "../../../dependencies.js"; -import { homeDirectory, testDataFolderPath } from "../../../globals.js"; - -import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; - -import saveSVG from "../../../../../assets/icons/save.svg?raw"; -import folderSVG from "../../../../../assets/icons/folder_open.svg?raw"; -import deleteSVG from "../../../../../assets/icons/delete.svg?raw"; -import generateSVG from "../../../../../assets/icons/restart.svg?raw"; -import downloadSVG from "../../../../../assets/download.svg?raw"; -import infoSVG from "../../../../../assets/info.svg?raw"; - -import { header } from "../../forms/utils"; - -import examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals"; -import { Modal } from "../../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - } -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "data/generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - } - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "data/generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - } - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error" - ); - - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning" - ); - } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error" - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify(`Test dataset successfully deleted from your system.`); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); - else { - this.notify("The test dataset no longer exists!", "warning"); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send("showItemInFolder", output_path); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, +} from "../../../../utils/electron.js"; + +import saveSVG from "../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../assets/icons/delete.svg?raw"; +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 examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath( + testDataFolderPath, + "multi_session_dataset", +); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => + fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone( + info.interfaces ? info.metadata ?? {} : {}, + ); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + }, +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) + return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + }, + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + }, + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify( + `Test dataset successfully generated at ${sanitizedOutputPath}!`, + ); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) + return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) + return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error", + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce( + (acc, v) => (acc += v === true ? 1 : 0), + 0, + ); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning", + ); + } else if (nSuccessful) + this.#openNotyf( + `Generated ${nSuccessful} test pipelines.`, + "success", + ); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error", + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement([ + "developer", + "testing_data_folder", + ]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && + fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify( + `Test dataset successfully deleted from your system.`, + ); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send( + "showItemInFolder", + DATASET_OUTPUT_PATH, + ); + else { + this.notify( + "The test dataset no longer exists!", + "warning", + ); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send( + "showItemInFolder", + output_path, + ); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || + customElements.define("nwbguide-settings-page", SettingsPage); From 96d5c891c39f6e2cdbcb89122ae35a4f15738fb0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 19:04:59 +0000 Subject: [PATCH 12/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../components/pages/settings/SettingsPage.js | 918 +++++++++--------- 1 file changed, 438 insertions(+), 480 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index f63c09690d..383d8840e1 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,480 +1,438 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; - -import { notyf } from "../../../dependencies.js"; -import { homeDirectory, testDataFolderPath } from "../../../globals.js"; - -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, -} from "../../../../utils/electron.js"; - -import saveSVG from "../../../../assets/icons/save.svg?raw"; -import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; -import deleteSVG from "../../../../assets/icons/delete.svg?raw"; -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 examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals"; -import { Modal } from "../../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath( - testDataFolderPath, - "multi_session_dataset", -); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => - fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone( - info.interfaces ? info.metadata ?? {} : {}, - ); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - }, -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) - return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "data/generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "data/generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify( - `Test dataset successfully generated at ${sanitizedOutputPath}!`, - ); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) - return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) - return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error", - ); - - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce( - (acc, v) => (acc += v === true ? 1 : 0), - 0, - ); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning", - ); - } else if (nSuccessful) - this.#openNotyf( - `Generated ${nSuccessful} test pipelines.`, - "success", - ); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error", - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement([ - "developer", - "testing_data_folder", - ]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && - fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify( - `Test dataset successfully deleted from your system.`, - ); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send( - "showItemInFolder", - DATASET_OUTPUT_PATH, - ); - else { - this.notify( - "The test dataset no longer exists!", - "warning", - ); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send( - "showItemInFolder", - output_path, - ); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || - customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; + +import saveSVG from "../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../assets/icons/delete.svg?raw"; +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 examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + } +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error" + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning" + ); + } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error" + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify(`Test dataset successfully deleted from your system.`); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); + else { + this.notify("The test dataset no longer exists!", "warning"); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send("showItemInFolder", output_path); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); From c36b4611676dc5ee7d26292b9842018f1632ff68 Mon Sep 17 00:00:00 2001 From: Cody Baker Date: Wed, 29 May 2024 15:13:43 -0400 Subject: [PATCH 13/18] fix relative paths again --- .../frontend/core/components/pages/settings/SettingsPage.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index f63c09690d..41b6f6b301 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -35,8 +35,8 @@ import { header } from "../../forms/utils"; import examplePipelines from "../../../../../../example_pipelines.yml"; import { run } from "../guided-mode/options/utils.js"; import { joinPath } from "../../../globals"; -import { Modal } from "../../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../../ProgressBar"; +import { Modal } from "../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); const DATASET_OUTPUT_PATH = joinPath( From d6cd1b7fa581cbc8411b5e2fec068d76cec8bb52 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 19:14:47 +0000 Subject: [PATCH 14/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../components/pages/settings/SettingsPage.js | 918 +++++++++--------- 1 file changed, 438 insertions(+), 480 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 41b6f6b301..2330117393 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -1,480 +1,438 @@ -import { html } from "lit"; -import { JSONSchemaForm } from "../../JSONSchemaForm.js"; -import { Page } from "../Page.js"; -import { onThrow } from "../../../errors"; -import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; -import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; - -import { validateDANDIApiKey } from "../../../validation/dandi"; - -import { Button } from "../../Button.js"; -import { global, remove, save } from "../../../progress/index.js"; -import { merge, setUndefinedIfNotDeclared } from "../utils"; - -import { notyf } from "../../../dependencies.js"; -import { homeDirectory, testDataFolderPath } from "../../../globals.js"; - -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, -} from "../../../../utils/electron.js"; - -import saveSVG from "../../../../assets/icons/save.svg?raw"; -import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; -import deleteSVG from "../../../../assets/icons/delete.svg?raw"; -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 examplePipelines from "../../../../../../example_pipelines.yml"; -import { run } from "../guided-mode/options/utils.js"; -import { joinPath } from "../../../globals"; -import { Modal } from "../../Modal"; -import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; - -const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); -const DATASET_OUTPUT_PATH = joinPath( - testDataFolderPath, - "multi_session_dataset", -); - -const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; - -const deleteIfExists = (path) => - fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""; - -function saveNewPipelineFromYaml(name, info, rootFolder) { - const subject_id = "mouse1"; - const sessions = ["session1"]; - const session_id = sessions[0]; - - info = structuredClone(info); // Copy info - - const hasMultipleSessions = sessions.length > 1; - - const resolvedInterfaces = info.interfaces ?? info; - - Object.values(resolvedInterfaces).forEach((info) => { - propertiesToTransform.forEach((property) => { - if (info[property]) { - const fullPath = path.join(rootFolder, info[property]); - if (fs.existsSync(fullPath)) info[property] = fullPath; - else throw new Error("Source data not available for this pipeline."); - } - }); - }); - - const resolvedMetadata = { - NWBFile: { session_id }, - Subject: { subject_id }, - }; - - resolvedMetadata.__generated = structuredClone( - info.interfaces ? info.metadata ?? {} : {}, - ); - - const resolvedInfo = { - source_data: resolvedInterfaces, - metadata: resolvedMetadata, - }; - - const updatedName = header(name); - - remove(updatedName, true); - - const workflowInfo = { - multiple_sessions: hasMultipleSessions, - }; - - if (!workflowInfo.multiple_sessions) { - workflowInfo.subject_id = subject_id; - workflowInfo.session_id = session_id; - } - - save({ - info: { - globalState: { - project: { - name: updatedName, - initialized: true, - workflow: workflowInfo, - }, - - // provide data for all supported interfaces - interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { - acc[key] = `${key}`; - return acc; - }, {}), - - structure: {}, - - results: { - [subject_id]: sessions.reduce((acc, sessionId) => { - acc[session_id] = resolvedInfo; - return acc; - }, {}), - }, - - subjects: { - [subject_id]: { - sessions: sessions, - sex: "M", - species: "Mus musculus", - age: "P30D", - }, - }, - }, - }, - }); -} - -const schema = merge( - projectGlobalSchema, - { - properties: { - DANDI: { - title: "DANDI Settings", - ...dandiGlobalSchema, - }, - developer: { - title: "Developer Settings", - ...developerGlobalSchema, - }, - }, - required: ["DANDI", "developer"], - }, - { - arrays: "append", - }, -); - -export class SettingsPage extends Page { - header = { - title: "App Settings", - subtitle: "This page allows you to set global settings for the GUIDE.", - controls: [ - new Button({ - icon: saveSVG, - onClick: async () => { - if (!this.unsavedUpdates) - return this.#openNotyf("All changes were already saved", "success"); - this.save(); - }, - }), - ], - }; - - constructor(...args) { - super(...args); - this.style.height = "100%"; // Fix main section - } - - #notification; - - #openNotyf = (message, type) => { - if (this.#notification) notyf.dismiss(this.#notification); - return (this.#notification = this.notify(message, type)); - }; - - deleteTestData = () => { - deleteIfExists(DATA_OUTPUT_PATH); - deleteIfExists(DATASET_OUTPUT_PATH); - }; - - generateTestData = async () => { - if (!fs.existsSync(DATA_OUTPUT_PATH)) { - await run( - "data/generate", - { - output_path: DATA_OUTPUT_PATH, - }, - { - title: "Generating test data", - html: "This will take several minutes to complete.", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - } - - await run( - "data/generate/dataset", - { - input_path: DATA_OUTPUT_PATH, - output_path: DATASET_OUTPUT_PATH, - }, - { - title: "Generating test dataset", - base: "data", - }, - ).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); - - const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); - - this.notify( - `Test dataset successfully generated at ${sanitizedOutputPath}!`, - ); - - return DATASET_OUTPUT_PATH; - }; - - beforeSave = async () => { - const { resolved } = this.form; - setUndefinedIfNotDeclared(schema.properties, resolved); - - merge(resolved, global.data); - - global.save(); // Save the changes, even if invalid on the form - this.#openNotyf(`Global settings changes saved.`, "success"); - }; - - #releaseNotesModal; - - // Populate the Update Available display - updated() { - const updateDiv = this.querySelector("#update-available"); - - if (updateDiv.innerHTML) return; // Only populate once - - onUpdateAvailable((updateInfo) => { - console.warn("Update Available", updateInfo); - - const relativePath = updateInfo.path; - const file = updateInfo.files.find((f) => f.url === relativePath); - const filesize = file.size; - - const container = document.createElement("div"); - container.classList.add("update-container"); - - const mainUpdateInfo = document.createElement("div"); - - const infoIcon = document.createElement("slot"); - infoIcon.innerHTML = infoSVG; - - infoIcon.onclick = () => { - if (this.#releaseNotesModal) - return (this.#releaseNotesModal.open = true); - - const modal = (this.#releaseNotesModal = new Modal({ - header: `Release Notes`, - })); - - const releaseNotes = document.createElement("div"); - releaseNotes.style.padding = "25px"; - releaseNotes.innerHTML = updateInfo.releaseNotes; - modal.append(releaseNotes); - - document.body.append(modal); - - modal.open = true; - }; - - const controls = document.createElement("div"); - controls.classList.add("controls"); - const downloadButton = new Button({ - icon: downloadSVG, - label: `Update (${humanReadableBytes(filesize)})`, - size: "extra-small", - onClick: () => electron.ipcRenderer.send("download-update"), - }); - - controls.append(downloadButton); - - const header = document.createElement("div"); - header.classList.add("header"); - - const title = document.createElement("h4"); - title.innerText = `NWB GUIDE ${updateInfo.version}`; - header.append(title, infoIcon); - - const description = document.createElement("span"); - description.innerText = `A new version of the application is available.`; - - mainUpdateInfo.append(header, description); - - container.append(mainUpdateInfo, controls); - - let progressBarEl; - onUpdateProgress((progress) => { - if (!progressBarEl) { - progressBarEl = new ProgressBar({ - isBytes: true, - format: { total: filesize }, - }); - const hr = document.createElement("hr"); - updateDiv.append(hr, progressBarEl); - } - progressBarEl.format = { - prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, - ...progress, - }; - }); - updateDiv.append(container); - }); - } - - render() { - this.localState = structuredClone(global.data); - - // NOTE: API Keys and Dandiset IDs persist across selected project - this.form = new JSONSchemaForm({ - results: this.localState, - schema, - onUpdate: () => (this.unsavedUpdates = true), - validateOnChange: async (name, parent) => { - const value = parent[name]; - if (name.includes("api_key")) - return await validateDANDIApiKey(value, name.includes("staging")); - return true; - }, - onThrow, - }); - - const generatePipelineButton = new Button({ - label: "Generate Test Pipelines", - onClick: async () => { - const { testing_data_folder } = this.form.results.developer ?? {}; - - if (!testing_data_folder) - return this.#openNotyf( - `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, - "error", - ); - - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); - - const resolved = pipelineNames.reverse().map((name) => { - try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); - return true; - } catch (e) { - console.error(e); - return name; - } - }); - - const nSuccessful = resolved.reduce( - (acc, v) => (acc += v === true ? 1 : 0), - 0, - ); - const nFailed = resolved.length - nSuccessful; - - if (nFailed) { - const failDisplay = - nFailed === 1 - ? `the ${resolved.find((v) => typeof v === "string")} pipeline` - : `${nFailed} pipelines`; - this.#openNotyf( - `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, - "warning", - ); - } else if (nSuccessful) - this.#openNotyf( - `Generated ${nSuccessful} test pipelines.`, - "success", - ); - else - this.#openNotyf( - `

Pipeline Generation Failed

Could not find source data for any pipelines.`, - "error", - ); - }, - }); - - setTimeout(() => { - const testFolderInput = this.form.getFormElement([ - "developer", - "testing_data_folder", - ]); - testFolderInput.after(generatePipelineButton); - }, 100); - - return html` -
-
-

Server Port: ${port}

-

Server File Location: ${SERVER_FILE_PATH}

-
-
-

Test Dataset

-
- ${fs.existsSync(DATASET_OUTPUT_PATH) && - fs.existsSync(DATA_OUTPUT_PATH) - ? [ - new Button({ - icon: deleteSVG, - label: "Delete", - size: "small", - onClick: async () => { - this.deleteTestData(); - this.notify( - `Test dataset successfully deleted from your system.`, - ); - this.requestUpdate(); - }, - }), - - new Button({ - icon: folderSVG, - label: "Open", - size: "small", - onClick: async () => { - if (electron.ipcRenderer) { - if (fs.existsSync(DATASET_OUTPUT_PATH)) - electron.ipcRenderer.send( - "showItemInFolder", - DATASET_OUTPUT_PATH, - ); - else { - this.notify( - "The test dataset no longer exists!", - "warning", - ); - this.requestUpdate(); - } - } - }, - }), - ] - : new Button({ - label: "Generate", - icon: generateSVG, - size: "small", - onClick: async () => { - const output_path = await this.generateTestData(); - if (electron.ipcRenderer) - electron.ipcRenderer.send( - "showItemInFolder", - output_path, - ); - this.requestUpdate(); - }, - })} -
-
-
-
-
-
- ${this.form} - `; - } -} - -customElements.get("nwbguide-settings-page") || - customElements.define("nwbguide-settings-page", SettingsPage); +import { html } from "lit"; +import { JSONSchemaForm } from "../../JSONSchemaForm.js"; +import { Page } from "../Page.js"; +import { onThrow } from "../../../errors"; +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; +import projectGlobalSchema from "../../../../../../schemas/json/project/globals.json" assert { type: "json" }; +import developerGlobalSchema from "../../../../../../schemas/json/developer/globals.json" assert { type: "json" }; + +import { validateDANDIApiKey } from "../../../validation/dandi"; + +import { Button } from "../../Button.js"; +import { global, remove, save } from "../../../progress/index.js"; +import { merge, setUndefinedIfNotDeclared } from "../utils"; + +import { notyf } from "../../../dependencies.js"; +import { homeDirectory, testDataFolderPath } from "../../../globals.js"; + +import { SERVER_FILE_PATH, electron, path, port, fs } from "../../../../utils/electron.js"; + +import saveSVG from "../../../../assets/icons/save.svg?raw"; +import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; +import deleteSVG from "../../../../assets/icons/delete.svg?raw"; +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 examplePipelines from "../../../../../../example_pipelines.yml"; +import { run } from "../guided-mode/options/utils.js"; +import { joinPath } from "../../../globals"; +import { Modal } from "../../Modal"; +import { ProgressBar, humanReadableBytes } from "../../ProgressBar"; + +const DATA_OUTPUT_PATH = joinPath(testDataFolderPath, "single_session_data"); +const DATASET_OUTPUT_PATH = joinPath(testDataFolderPath, "multi_session_dataset"); + +const propertiesToTransform = ["folder_path", "file_path", "config_file_path"]; + +const deleteIfExists = (path) => (fs.existsSync(path) ? fs.rmSync(path, { recursive: true }) : ""); + +function saveNewPipelineFromYaml(name, info, rootFolder) { + const subject_id = "mouse1"; + const sessions = ["session1"]; + const session_id = sessions[0]; + + info = structuredClone(info); // Copy info + + const hasMultipleSessions = sessions.length > 1; + + const resolvedInterfaces = info.interfaces ?? info; + + Object.values(resolvedInterfaces).forEach((info) => { + propertiesToTransform.forEach((property) => { + if (info[property]) { + const fullPath = path.join(rootFolder, info[property]); + if (fs.existsSync(fullPath)) info[property] = fullPath; + else throw new Error("Source data not available for this pipeline."); + } + }); + }); + + const resolvedMetadata = { + NWBFile: { session_id }, + Subject: { subject_id }, + }; + + resolvedMetadata.__generated = structuredClone(info.interfaces ? info.metadata ?? {} : {}); + + const resolvedInfo = { + source_data: resolvedInterfaces, + metadata: resolvedMetadata, + }; + + const updatedName = header(name); + + remove(updatedName, true); + + const workflowInfo = { + multiple_sessions: hasMultipleSessions, + }; + + if (!workflowInfo.multiple_sessions) { + workflowInfo.subject_id = subject_id; + workflowInfo.session_id = session_id; + } + + save({ + info: { + globalState: { + project: { + name: updatedName, + initialized: true, + workflow: workflowInfo, + }, + + // provide data for all supported interfaces + interfaces: Object.keys(resolvedInterfaces).reduce((acc, key) => { + acc[key] = `${key}`; + return acc; + }, {}), + + structure: {}, + + results: { + [subject_id]: sessions.reduce((acc, sessionId) => { + acc[session_id] = resolvedInfo; + return acc; + }, {}), + }, + + subjects: { + [subject_id]: { + sessions: sessions, + sex: "M", + species: "Mus musculus", + age: "P30D", + }, + }, + }, + }, + }); +} + +const schema = merge( + projectGlobalSchema, + { + properties: { + DANDI: { + title: "DANDI Settings", + ...dandiGlobalSchema, + }, + developer: { + title: "Developer Settings", + ...developerGlobalSchema, + }, + }, + required: ["DANDI", "developer"], + }, + { + arrays: "append", + } +); + +export class SettingsPage extends Page { + header = { + title: "App Settings", + subtitle: "This page allows you to set global settings for the GUIDE.", + controls: [ + new Button({ + icon: saveSVG, + onClick: async () => { + if (!this.unsavedUpdates) return this.#openNotyf("All changes were already saved", "success"); + this.save(); + }, + }), + ], + }; + + constructor(...args) { + super(...args); + this.style.height = "100%"; // Fix main section + } + + #notification; + + #openNotyf = (message, type) => { + if (this.#notification) notyf.dismiss(this.#notification); + return (this.#notification = this.notify(message, type)); + }; + + deleteTestData = () => { + deleteIfExists(DATA_OUTPUT_PATH); + deleteIfExists(DATASET_OUTPUT_PATH); + }; + + generateTestData = async () => { + if (!fs.existsSync(DATA_OUTPUT_PATH)) { + await run( + "data/generate", + { + output_path: DATA_OUTPUT_PATH, + }, + { + title: "Generating test data", + html: "This will take several minutes to complete.", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + } + + await run( + "data/generate/dataset", + { + input_path: DATA_OUTPUT_PATH, + output_path: DATASET_OUTPUT_PATH, + }, + { + title: "Generating test dataset", + base: "data", + } + ).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const sanitizedOutputPath = DATASET_OUTPUT_PATH.replace(homeDirectory, "~"); + + this.notify(`Test dataset successfully generated at ${sanitizedOutputPath}!`); + + return DATASET_OUTPUT_PATH; + }; + + beforeSave = async () => { + const { resolved } = this.form; + setUndefinedIfNotDeclared(schema.properties, resolved); + + merge(resolved, global.data); + + global.save(); // Save the changes, even if invalid on the form + this.#openNotyf(`Global settings changes saved.`, "success"); + }; + + #releaseNotesModal; + + // Populate the Update Available display + updated() { + const updateDiv = this.querySelector("#update-available"); + + if (updateDiv.innerHTML) return; // Only populate once + + onUpdateAvailable((updateInfo) => { + console.warn("Update Available", updateInfo); + + const relativePath = updateInfo.path; + const file = updateInfo.files.find((f) => f.url === relativePath); + const filesize = file.size; + + const container = document.createElement("div"); + container.classList.add("update-container"); + + const mainUpdateInfo = document.createElement("div"); + + const infoIcon = document.createElement("slot"); + infoIcon.innerHTML = infoSVG; + + infoIcon.onclick = () => { + if (this.#releaseNotesModal) return (this.#releaseNotesModal.open = true); + + const modal = (this.#releaseNotesModal = new Modal({ + header: `Release Notes`, + })); + + const releaseNotes = document.createElement("div"); + releaseNotes.style.padding = "25px"; + releaseNotes.innerHTML = updateInfo.releaseNotes; + modal.append(releaseNotes); + + document.body.append(modal); + + modal.open = true; + }; + + const controls = document.createElement("div"); + controls.classList.add("controls"); + const downloadButton = new Button({ + icon: downloadSVG, + label: `Update (${humanReadableBytes(filesize)})`, + size: "extra-small", + onClick: () => electron.ipcRenderer.send("download-update"), + }); + + controls.append(downloadButton); + + const header = document.createElement("div"); + header.classList.add("header"); + + const title = document.createElement("h4"); + title.innerText = `NWB GUIDE ${updateInfo.version}`; + header.append(title, infoIcon); + + const description = document.createElement("span"); + description.innerText = `A new version of the application is available.`; + + mainUpdateInfo.append(header, description); + + container.append(mainUpdateInfo, controls); + + let progressBarEl; + onUpdateProgress((progress) => { + if (!progressBarEl) { + progressBarEl = new ProgressBar({ + isBytes: true, + format: { total: filesize }, + }); + const hr = document.createElement("hr"); + updateDiv.append(hr, progressBarEl); + } + progressBarEl.format = { + prefix: `Download Progress for NWB GUIDE ${updateInfo.version}`, + ...progress, + }; + }); + updateDiv.append(container); + }); + } + + render() { + this.localState = structuredClone(global.data); + + // NOTE: API Keys and Dandiset IDs persist across selected project + this.form = new JSONSchemaForm({ + results: this.localState, + schema, + onUpdate: () => (this.unsavedUpdates = true), + validateOnChange: async (name, parent) => { + const value = parent[name]; + if (name.includes("api_key")) return await validateDANDIApiKey(value, name.includes("staging")); + return true; + }, + onThrow, + }); + + const generatePipelineButton = new Button({ + label: "Generate Test Pipelines", + onClick: async () => { + const { testing_data_folder } = this.form.results.developer ?? {}; + + if (!testing_data_folder) + return this.#openNotyf( + `Please specify a testing data folder in the Developer section before attempting to generate pipelines.`, + "error" + ); + + const { pipelines = {} } = examplePipelines; + + const pipelineNames = Object.keys(pipelines); + + const resolved = pipelineNames.reverse().map((name) => { + try { + saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + return true; + } catch (e) { + console.error(e); + return name; + } + }); + + const nSuccessful = resolved.reduce((acc, v) => (acc += v === true ? 1 : 0), 0); + const nFailed = resolved.length - nSuccessful; + + if (nFailed) { + const failDisplay = + nFailed === 1 + ? `the ${resolved.find((v) => typeof v === "string")} pipeline` + : `${nFailed} pipelines`; + this.#openNotyf( + `

Generated ${nSuccessful} test pipelines.

Could not find source data for ${failDisplay}.`, + "warning" + ); + } else if (nSuccessful) this.#openNotyf(`Generated ${nSuccessful} test pipelines.`, "success"); + else + this.#openNotyf( + `

Pipeline Generation Failed

Could not find source data for any pipelines.`, + "error" + ); + }, + }); + + setTimeout(() => { + const testFolderInput = this.form.getFormElement(["developer", "testing_data_folder"]); + testFolderInput.after(generatePipelineButton); + }, 100); + + return html` +
+
+

Server Port: ${port}

+

Server File Location: ${SERVER_FILE_PATH}

+
+
+

Test Dataset

+
+ ${fs.existsSync(DATASET_OUTPUT_PATH) && fs.existsSync(DATA_OUTPUT_PATH) + ? [ + new Button({ + icon: deleteSVG, + label: "Delete", + size: "small", + onClick: async () => { + this.deleteTestData(); + this.notify(`Test dataset successfully deleted from your system.`); + this.requestUpdate(); + }, + }), + + new Button({ + icon: folderSVG, + label: "Open", + size: "small", + onClick: async () => { + if (electron.ipcRenderer) { + if (fs.existsSync(DATASET_OUTPUT_PATH)) + electron.ipcRenderer.send("showItemInFolder", DATASET_OUTPUT_PATH); + else { + this.notify("The test dataset no longer exists!", "warning"); + this.requestUpdate(); + } + } + }, + }), + ] + : new Button({ + label: "Generate", + icon: generateSVG, + size: "small", + onClick: async () => { + const output_path = await this.generateTestData(); + if (electron.ipcRenderer) + electron.ipcRenderer.send("showItemInFolder", output_path); + this.requestUpdate(); + }, + })} +
+
+
+
+
+
+ ${this.form} + `; + } +} + +customElements.get("nwbguide-settings-page") || customElements.define("nwbguide-settings-page", SettingsPage); From a78b4d733f7ba0c9f848f16ca6a72c6018bbdd3b Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 13:13:53 -0700 Subject: [PATCH 15/18] Update SettingsPage.js --- .../frontend/core/components/pages/settings/SettingsPage.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index 2330117393..e351bda9ce 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -335,13 +335,11 @@ export class SettingsPage extends Page { "error" ); - const { pipelines = {} } = examplePipelines; - - const pipelineNames = Object.keys(pipelines); + const pipelineNames = Object.keys(examplePipelines); const resolved = pipelineNames.reverse().map((name) => { try { - saveNewPipelineFromYaml(name, pipelines[name], testing_data_folder); + saveNewPipelineFromYaml(name, examplePipelines[name], testing_data_folder); return true; } catch (e) { console.error(e); From 43b1ed6bc83395007c5f3889318a66b103d2bce1 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 13:17:21 -0700 Subject: [PATCH 16/18] Update SettingsPage.js --- .../core/components/pages/settings/SettingsPage.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index e351bda9ce..c72556a087 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -15,7 +15,15 @@ import { merge, setUndefinedIfNotDeclared } from "../utils"; 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, + onUpdateAvailable, + onUpdateProgress +} from "../../../../utils/electron.js"; import saveSVG from "../../../../assets/icons/save.svg?raw"; import folderSVG from "../../../../assets/icons/folder_open.svg?raw"; From 4aa5384867c5661f690fe84d6681cdbe5519b0db Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 29 May 2024 20:17:38 +0000 Subject: [PATCH 17/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../components/pages/settings/SettingsPage.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/electron/frontend/core/components/pages/settings/SettingsPage.js b/src/electron/frontend/core/components/pages/settings/SettingsPage.js index c72556a087..74e8501f49 100644 --- a/src/electron/frontend/core/components/pages/settings/SettingsPage.js +++ b/src/electron/frontend/core/components/pages/settings/SettingsPage.js @@ -15,14 +15,14 @@ import { merge, setUndefinedIfNotDeclared } from "../utils"; import { notyf } from "../../../dependencies.js"; import { homeDirectory, testDataFolderPath } from "../../../globals.js"; -import { - SERVER_FILE_PATH, - electron, - path, - port, - fs, - onUpdateAvailable, - onUpdateProgress +import { + SERVER_FILE_PATH, + electron, + path, + port, + fs, + onUpdateAvailable, + onUpdateProgress, } from "../../../../utils/electron.js"; import saveSVG from "../../../../assets/icons/save.svg?raw"; From 9051dfdf9f98b22b033db9c29507f41e9b6ae0f9 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Wed, 29 May 2024 13:53:47 -0700 Subject: [PATCH 18/18] Update main.ts --- src/electron/main/main.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/electron/main/main.ts b/src/electron/main/main.ts index 511eb15ef0..8ce2926c69 100755 --- a/src/electron/main/main.ts +++ b/src/electron/main/main.ts @@ -455,7 +455,8 @@ app.on("before-quit", async (ev: Event) => { type: "question", buttons: ["Yes", "No"], title: "Confirm", - message: "Any running process will be stopped. Are you sure you want to quit?", + message: 'Are you sure you want to quit?', + detail: 'Any running processes will be stopped.' }) if (response !== 0) return // Skip quitting