From 1340258e190b54f013414b9af52b51cafd753388 Mon Sep 17 00:00:00 2001 From: Garrett Michael Flynn Date: Tue, 12 Mar 2024 19:09:22 -0700 Subject: [PATCH] Pre-Form Basic Implementation (#633) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Cody Baker <51133164+CodyCBakerPhD@users.noreply.github.com> --- .codespellrc | 2 +- src/renderer/src/pages.js | 7 + src/renderer/src/progress/operations.js | 2 - src/renderer/src/stories/Dashboard.js | 27 ++- src/renderer/src/stories/JSONSchemaInput.js | 2 + src/renderer/src/stories/Main.js | 19 +- src/renderer/src/stories/NavigationSidebar.js | 2 +- .../pages/guided-mode/data/GuidedMetadata.js | 30 ++- .../guided-mode/data/GuidedPathExpansion.js | 219 ++++++++---------- .../guided-mode/data/GuidedSourceData.js | 39 +++- .../stories/pages/guided-mode/data/utils.js | 14 +- .../pages/guided-mode/setup/GuidedSubjects.js | 53 +++-- .../pages/guided-mode/setup/Preform.js | 117 ++++++++++ .../stories/pages/settings/SettingsPage.js | 5 +- tests/e2e.test.ts | 135 ++++++++--- 15 files changed, 462 insertions(+), 211 deletions(-) create mode 100644 src/renderer/src/stories/pages/guided-mode/setup/Preform.js diff --git a/.codespellrc b/.codespellrc index 6b0aa92f0..5afc45ef0 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,3 +1,3 @@ [codespell] -ignore-words-list= afterall +ignore-words-list= afterall,preform skip = .git,*.svg,package-lock.json,node_modules,*lotties*,nwb-guide.spec,prepare_pyinstaller_spec.py diff --git a/src/renderer/src/pages.js b/src/renderer/src/pages.js index 603edade8..55fe531b7 100644 --- a/src/renderer/src/pages.js +++ b/src/renderer/src/pages.js @@ -27,6 +27,7 @@ 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"; let dashboard = document.querySelector("nwb-dashboard"); if (!dashboard) dashboard = new Dashboard(); @@ -93,6 +94,12 @@ const pages = { 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", diff --git a/src/renderer/src/progress/operations.js b/src/renderer/src/progress/operations.js index 10e25e533..83cc9ce66 100644 --- a/src/renderer/src/progress/operations.js +++ b/src/renderer/src/progress/operations.js @@ -12,8 +12,6 @@ export const remove = (name) => { } else localStorage.removeItem(progressFilePathToDelete); if (fs) { - console.log(previewSaveFolderPath, conversionSaveFolderPath, name); - // delete default preview location fs.rmSync(joinPath(previewSaveFolderPath, name), { recursive: true, force: true }); diff --git a/src/renderer/src/stories/Dashboard.js b/src/renderer/src/stories/Dashboard.js index 6099785f9..f4fda44ac 100644 --- a/src/renderer/src/stories/Dashboard.js +++ b/src/renderer/src/stories/Dashboard.js @@ -157,6 +157,8 @@ export class Dashboard extends LitElement { 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); @@ -214,8 +216,6 @@ export class Dashboard extends LitElement { this.page.set(toPass, false); this.page.checkSyncState().then(() => { - this.page.requestUpdate(); // Re-render page - const projectName = info.globalState?.project?.name; this.subSidebar.header = projectName @@ -228,6 +228,18 @@ export class Dashboard extends LitElement { }); 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) { + // 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.info.previous === this.page) this.page.onTransition(-1); + else this.page.onTransition(1); + } }); } @@ -260,6 +272,17 @@ export class Dashboard extends LitElement { state.active = false; pageState.active = false; + // Check if page is skipped based on workflow state (if applicable) + if (page.workflow) { + const workflow = page.workflow; + const workflowValues = globalState.project?.workflow ?? {}; + const skipped = Object.entries(workflow).some(([key, state]) => { + if (!workflowValues[key]) return state.skip; + }); + + pageState.skipped = skipped; + } + if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states if (!("visited" in pageState)) pageState.visited = false; diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index ba4f33fc7..8445f4723 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -541,6 +541,8 @@ export class JSONSchemaInput extends LitElement { if (!forceValidate) { // Update the actual input element const inputElement = this.getElement(); + if (!inputElement) return false; + if (inputElement.type === "checkbox") inputElement.checked = value; else if (inputElement.classList.contains("list")) { const list = inputElement.children[0]; diff --git a/src/renderer/src/stories/Main.js b/src/renderer/src/stories/Main.js index f5d7205ef..f556cd137 100644 --- a/src/renderer/src/stories/Main.js +++ b/src/renderer/src/stories/Main.js @@ -60,6 +60,24 @@ export class Main extends LitElement { page.onTransition = this.onTransition; page.updatePages = this.updatePages; + // Constrain based on workflow configuration + const workflowConfig = page.workflow ?? (page.workflow = {}); + const workflowValues = page.info.globalState?.project?.workflow ?? {}; + + Object.entries(workflowConfig).forEach(([key, state]) => { + workflowConfig[key].value = workflowValues[key]; + + const value = workflowValues[key]; + + if (state.elements) { + const elements = state.elements; + if (value) elements.forEach((el) => el.removeAttribute("hidden")); + else elements.forEach((el) => el.setAttribute("hidden", true)); + } + }); + + page.requestUpdate(); // Ensure the page is re-rendered with new workflow configurations + if (this.content) this.toRender = toRender.page ? toRender : { page }; else this.#queue.push(page); } @@ -130,7 +148,6 @@ export class Main extends LitElement { if (header === true || !("header" in page) || !("sections" in page.header)) { const sectionNames = Object.keys(sections); - header = page.header && typeof page.header === "object" ? page.header : {}; header.sections = sectionNames; header.selected = sectionNames.indexOf(info.section); diff --git a/src/renderer/src/stories/NavigationSidebar.js b/src/renderer/src/stories/NavigationSidebar.js index 71b00cf34..168c953fb 100644 --- a/src/renderer/src/stories/NavigationSidebar.js +++ b/src/renderer/src/stories/NavigationSidebar.js @@ -129,7 +129,7 @@ export class NavigationSidebar extends LitElement { class=" guided--nav-bar-section-page hidden - ${state.visited ? " completed" : " not-completed"} + ${state.visited && !state.skipped ? " completed" : " not-completed"} ${state.active ? "active" : ""}"f " @click=${() => this.onClick(id)} diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js index d68dae20d..a6231e903 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -100,17 +100,23 @@ export class GuidedMetadataPage extends ManagedPage { form; + #globalButton = new Button({ + icon: globalIcon, + label: "Edit Global Metadata", + onClick: () => { + this.#globalModal.form.results = structuredClone(this.info.globalState.project); + this.#globalModal.open = true; + }, + }); + + workflow = { + multiple_sessions: { + elements: [this.#globalButton], + }, + }; + header = { - controls: [ - new Button({ - icon: globalIcon, - label: "Edit Global Metadata", - onClick: () => { - this.#globalModal.form.results = structuredClone(this.info.globalState.project); - this.#globalModal.open = true; - }, - }), - ], + controls: [this.#globalButton], subtitle: "Edit all metadata for this conversion at the session level", }; @@ -156,6 +162,8 @@ export class GuidedMetadataPage extends ManagedPage { } createForm = ({ subject, session, info }) => { + const hasMultipleSessions = this.workflow.multiple_sessions.value; + // const results = createResults({ subject, info }, this.info.globalState); const { globalState } = this.info; @@ -163,7 +171,7 @@ export class GuidedMetadataPage extends ManagedPage { const results = info.metadata; // Edited form info // Define the appropriate global metadata to fill empty values in the form - const aggregateGlobalMetadata = resolveGlobalOverrides(subject, globalState); + const aggregateGlobalMetadata = resolveGlobalOverrides(subject, globalState, hasMultipleSessions); // Define the correct instance identifier const instanceId = `sub-${subject}/ses-${session}`; diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js index 8f88aec22..2609918c0 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedPathExpansion.js @@ -247,6 +247,8 @@ function getFiles(dir) { } export class GuidedPathExpansionPage extends Page { + #notification; + constructor(...args) { super(...args); } @@ -255,107 +257,109 @@ export class GuidedPathExpansionPage extends Page { subtitle: "Automatic source data detection for multiple subjects / sessions", }; - beforeSave = async () => { - const keepExistingData = this.dataManagementForm.resolved.keep_existing_data; - this.localState.keep_existing_data = keepExistingData; - - const globalState = this.info.globalState; - merge({ structure: this.localState }, globalState); // Merge the actual entries into the structure + #initialize = () => (this.localState = merge(this.info.globalState.structure, { results: {} })); - const hidden = this.optional.hidden; - globalState.structure.state = !hidden; + workflow = { + locate_data: { + skip: () => { + this.#initialize(); + const globalState = this.info.globalState; + merge({ structure: this.localState }, globalState); // Merge the actual entries into the structure - if (hidden) { - // Force single subject/session if not keeping existing data - if (!keepExistingData || !globalState.results) { - const existingMetadata = - globalState.results?.[this.altInfo.subject_id]?.[this.altInfo.session_id]?.metadata; + // Force single subject/session if not keeping existing data + if (!globalState.results) { + const existingMetadata = + globalState.results?.[this.altInfo.subject_id]?.[this.altInfo.session_id]?.metadata; - const existingSourceData = - globalState.results?.[this.altInfo.subject_id]?.[this.altInfo.session_id]?.source_data; + const existingSourceData = + globalState.results?.[this.altInfo.subject_id]?.[this.altInfo.session_id]?.source_data; - const source_data = {}; - for (let key in globalState.interfaces) { - const existing = existingSourceData?.[key]; - if (existing) source_data[key] = existing ?? {}; - } + const source_data = {}; + for (let key in globalState.interfaces) { + const existing = existingSourceData?.[key]; + if (existing) source_data[key] = existing ?? {}; + } - globalState.results = { - [this.altInfo.subject_id]: { - [this.altInfo.session_id]: { - source_data, - metadata: { - NWBFile: { - session_id: this.altInfo.session_id, - ...(existingMetadata?.NWBFile ?? {}), - }, - Subject: { - subject_id: this.altInfo.subject_id, - ...(existingMetadata?.Subject ?? {}), + globalState.results = { + [this.altInfo.subject_id]: { + [this.altInfo.session_id]: { + source_data, + metadata: { + NWBFile: { + session_id: this.altInfo.session_id, + ...(existingMetadata?.NWBFile ?? {}), + }, + Subject: { + subject_id: this.altInfo.subject_id, + ...(existingMetadata?.Subject ?? {}), + }, }, }, }, - }, - }; - } - } + }; + } + }, + }, + }; - // Otherwise use path expansion to merge into existing subjects - else if (!hidden && hidden !== undefined) { - const structure = globalState.structure.results; + beforeSave = async () => { + const globalState = this.info.globalState; + merge({ structure: this.localState }, globalState); // Merge the actual entries into the structure - await this.form.validate(); + const structure = globalState.structure.results; - const finalStructure = {}; - for (let key in structure) { - const entry = { ...structure[key] }; - const fstring = entry.format_string_path; - if (!fstring) continue; - if (fstring.split(".").length > 1) entry.file_path = fstring; - else entry.folder_path = fstring; - delete entry.format_string_path; - finalStructure[key] = entry; - } + await this.form.validate(); - const results = await run(`locate`, finalStructure, { title: "Locating Data" }).catch((error) => { - this.notify(error.message, "error"); - throw error; - }); + const finalStructure = {}; + for (let key in structure) { + const entry = { ...structure[key] }; + const fstring = entry.format_string_path; + if (!fstring) continue; + if (fstring.split(".").length > 1) entry.file_path = fstring; + else entry.folder_path = fstring; + delete entry.format_string_path; + finalStructure[key] = entry; + } - const subjects = Object.keys(results); - if (subjects.length === 0) { - const message = "No subjects found with the current configuration. Please try again."; - this.notify(message, "error"); - throw message; - } + const results = await run(`locate`, finalStructure, { title: "Locating Data" }).catch((error) => { + this.notify(error.message, "error"); + throw error; + }); + + const subjects = Object.keys(results); + if (subjects.length === 0) { + if (this.#notification) this.dismiss(this.#notification); + const message = "No subjects found with the current configuration. Please try again."; + this.#notification = this.notify(message, "error"); + throw message; + } - // Save an overall results object organized by subject and session - merge({ results }, globalState); + // Save an overall results object organized by subject and session + merge({ results }, globalState); - const globalResults = globalState.results; + const globalResults = globalState.results; - if (!keepExistingData) { - for (let sub in globalResults) { - const subRef = results[sub]; - if (!subRef) - delete globalResults[sub]; // Delete removed subjects - else { - for (let ses in globalResults[sub]) { - const sesRef = subRef[ses]; + if (!keepExistingData) { + for (let sub in globalResults) { + const subRef = results[sub]; + if (!subRef) + delete globalResults[sub]; // Delete removed subjects + else { + for (let ses in globalResults[sub]) { + const sesRef = subRef[ses]; - if (!sesRef) - delete globalResults[sub][ses]; // Delete removed sessions - else { - const globalSesRef = globalResults[sub][ses]; + if (!sesRef) + delete globalResults[sub][ses]; // Delete removed sessions + else { + const globalSesRef = globalResults[sub][ses]; - for (let name in globalSesRef.source_data) { - if (!sesRef.source_data[name]) delete globalSesRef.source_data[name]; // Delete removed interfaces - } + for (let name in globalSesRef.source_data) { + if (!sesRef.source_data[name]) delete globalSesRef.source_data[name]; // Delete removed interfaces } } - - if (Object.keys(globalResults[sub]).length === 0) delete globalResults[sub]; // Delete empty subjects } + + if (Object.keys(globalResults[sub]).length === 0) delete globalResults[sub]; // Delete empty subjects } } } @@ -365,11 +369,13 @@ export class GuidedPathExpansionPage extends Page { onNext: async () => { await this.save(); // Save in case the request fails - if (!this.optional.toggled) { - const message = "Please select an option."; - this.notify(message, "error"); - throw new Error(message); - } + await this.form.validate(); + + // if (!this.optional.toggled) { + // const message = "Please select an option."; + // this.notify(message, "error"); + // throw new Error(message); + // } return this.to(1); }, @@ -401,21 +407,7 @@ export class GuidedPathExpansionPage extends Page { localState = {}; render() { - this.optional = new OptionalSection({ - header: "Will you locate your files programmatically?", - description: infoBox, - onChange: () => (this.unsavedUpdates = "conversions"), - // altContent: this.altForm, - }); - - const structureState = (this.localState = merge(this.info.globalState.structure, { - results: {}, - keep_existing_data: true, - })); - - const state = structureState.state; - - this.optional.state = state; + const structureState = this.#initialize(); // Require properties for all sources const generatedSchema = { type: "object", properties: {}, additionalProperties: false }; @@ -438,7 +430,7 @@ export class GuidedPathExpansionPage extends Page { } structureState.schema = generatedSchema; - this.optional.requestUpdate(); + // this.optional.requestUpdate(); const form = (this.form = new JSONSchemaForm({ ...structureState, @@ -526,32 +518,9 @@ export class GuidedPathExpansionPage extends Page { }, })); - this.optional.innerHTML = ""; - - this.optional.style.paddingTop = "10px"; - - this.dataManagementForm = new JSONSchemaForm({ - results: { keep_existing_data: structureState.keep_existing_data }, - onUpdate: () => (this.unsavedUpdates = "conversions"), - schema: { - type: "object", - additionalProperties: false, - properties: { - keep_existing_data: { - type: "boolean", - description: "Maintain data for subjects / sessions that are not located.", - }, - }, - }, - }); - - this.optional.append(form); - form.style.width = "100%"; - this.scrollTop = "300px"; - - return html`${this.dataManagementForm}${this.optional}`; + return html`${infoBox}

${form}`; } } diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js index 93fb40053..6bd47bc30 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -42,21 +42,27 @@ export class GuidedSourceDataPage extends ManagedPage { merge(this.localState, this.info.globalState); }; + #globalButton = new Button({ + icon: globalIcon, + label: "Edit Global Source Data", + onClick: () => { + this.#globalModal.form.results = structuredClone(this.info.globalState.project.SourceData ?? {}); + this.#globalModal.open = true; + }, + }); + header = { - controls: [ - new Button({ - icon: globalIcon, - label: "Edit Global Source Data", - onClick: () => { - this.#globalModal.form.results = structuredClone(this.info.globalState.project.SourceData ?? {}); - this.#globalModal.open = true; - }, - }), - ], + controls: [this.#globalButton], subtitle: "Specify the file and folder locations on your local system for each interface, as well as any additional details that might be required.", }; + workflow = { + multiple_sessions: { + elements: [this.#globalButton], + }, + }; + footer = { onNext: async () => { await this.save(); // Save in case the conversion fails @@ -151,6 +157,8 @@ export class GuidedSourceDataPage extends ManagedPage { }; createForm = ({ subject, session, info }) => { + const hasMultipleSessions = this.workflow.multiple_sessions.value; + const instanceId = `sub-${subject}/ses-${session}`; const schema = this.info.globalState.schema.source_data; @@ -162,7 +170,7 @@ export class GuidedSourceDataPage extends ManagedPage { results: info.source_data, emptyMessage: "No source data required for this session.", ignore: propsToIgnore, - globals: this.info.globalState.project.SourceData, + globals: hasMultipleSessions ? this.info.globalState.project.SourceData : undefined, onOverride: (name) => { this.notify(`${header(name)} has been overridden with a global value.`, "warning", 3000); }, @@ -211,6 +219,15 @@ export class GuidedSourceDataPage extends ManagedPage { if (this.#globalModal) this.#globalModal.remove(); } + updated() { + const dashboard = document.querySelector("nwb-dashboard"); + const page = dashboard.page; + setTimeout(() => { + console.log(page.forms[0].form.accordions["SpikeGLX Recording"]); + }); + console.log(page.forms[0].form.accordions); + } + render() { this.localState = { results: structuredClone(this.info.globalState.results ?? {}) }; diff --git a/src/renderer/src/stories/pages/guided-mode/data/utils.js b/src/renderer/src/stories/pages/guided-mode/data/utils.js index 6593aba50..bbc3e0dbe 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/utils.js +++ b/src/renderer/src/stories/pages/guided-mode/data/utils.js @@ -24,15 +24,19 @@ export const getInfoFromId = (key) => { return { subject, session }; }; -export function resolveGlobalOverrides(subject, globalState) { - const subjectMetadataCopy = { ...globalState.subjects[subject] }; +export function resolveGlobalOverrides(subject, globalState, resolveMultiSessionOverrides = true) { + const subjectMetadataCopy = { ...(globalState.subjects?.[subject] ?? {}) }; delete subjectMetadataCopy.sessions; // Remove extra key from metadata - const overrides = structuredClone(globalState.project ?? {}); // Copy project-wide metadata + if (resolveMultiSessionOverrides) { + const overrides = structuredClone(globalState.project ?? {}); // Copy project-wide metadata - merge(subjectMetadataCopy, overrides.Subject ?? (overrides.Subject = {})); // Ensure Subject exists + merge(subjectMetadataCopy, overrides.Subject ?? (overrides.Subject = {})); // Ensure Subject exists - return overrides; + return overrides; + } + + return { Subject: subjectMetadataCopy }; } const isPatternResult = Symbol("ispatternresult"); diff --git a/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js b/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js index 4fbdced65..0f9dd7885 100644 --- a/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js +++ b/src/renderer/src/stories/pages/guided-mode/setup/GuidedSubjects.js @@ -18,18 +18,35 @@ export class GuidedSubjectsPage extends Page { super(...args); } + #addButton = new Button({ + label: "Add Subject", + onClick: () => this.table.table.alter("insert_row_below"), + }); + + #globalButton = new Button({ + icon: globalIcon, + label: "Edit Global Metadata", + onClick: () => { + this.#globalModal.form.results = structuredClone(this.info.globalState.project.Subject ?? {}); + this.#globalModal.open = true; + }, + }); + + workflow = { + multiple_sessions: { + elements: [this.#globalButton, this.#addButton], + }, + }; + header = { subtitle: "Enter all metadata known about each experiment subject", - controls: [ - new Button({ - icon: globalIcon, - label: "Edit Global Metadata", - onClick: () => { - this.#globalModal.form.results = structuredClone(this.info.globalState.project.Subject ?? {}); - this.#globalModal.open = true; - }, - }), - ], + controls: [this.#globalButton], + }; + + workflow = { + multiple_sessions: { + skip: () => {}, + }, }; // Abort save if subject structure is invalid @@ -72,8 +89,6 @@ export class GuidedSubjectsPage extends Page { footer = {}; updated() { - const add = this.query("#addButton"); - add.onclick = () => this.table.table.alter("insert_row_below"); super.updated(); // Call if updating data } @@ -104,6 +119,8 @@ export class GuidedSubjectsPage extends Page { } render() { + const hasMultipleSessions = this.workflow.multiple_sessions.value; + const subjects = (this.localState = structuredClone(this.info.globalState.subjects ?? {})); // Ensure all the proper subjects are in the global state @@ -117,18 +134,20 @@ export class GuidedSubjectsPage extends Page { subjects[subject].sessions = sessions; } + const contextMenuConfig = { ignore: ["row_below"] }; + + if (!hasMultipleSessions) contextMenuConfig.ignore.push("remove_row"); + this.table = new Table({ schema: { type: "array", items: getSubjectSchema(), }, data: subjects, - globals: this.info.globalState.project.Subject, + globals: hasMultipleSessions ? this.info.globalState.project.Subject : undefined, keyColumn: "subject_id", validateEmptyCells: ["subject_id", "sessions"], - contextMenu: { - ignore: ["row_below"], - }, + contextMenu: contextMenuConfig, onThrow: (message, type) => this.notify(message, type), onOverride: (name) => { this.notify(`${header(name)} has been overridden with a global value.`, "warning", 3000); @@ -157,7 +176,7 @@ export class GuidedSubjectsPage extends Page { return html`
${this.table}
- Add Subject + ${this.#addButton}
`; } diff --git a/src/renderer/src/stories/pages/guided-mode/setup/Preform.js b/src/renderer/src/stories/pages/guided-mode/setup/Preform.js new file mode 100644 index 000000000..1d5736c87 --- /dev/null +++ b/src/renderer/src/stories/pages/guided-mode/setup/Preform.js @@ -0,0 +1,117 @@ +import { html } from "lit"; +import { JSONSchemaForm } from "../../../JSONSchemaForm.js"; +import { Page } from "../../Page.js"; +import { onThrow } from "../../../../errors"; + +// ------------------------------------------------------------------------------ +// ------------------------ Preform Configuration ------------------------------- +// ------------------------------------------------------------------------------ + +const questions = { + multiple_sessions: { + type: "boolean", + title: "Will this pipeline be run on multiple sessions?", + default: false, + }, + locate_data: { + type: "boolean", + title: "Would you like to locate these sessions programmatically?", + dependencies: ["multiple_sessions"], + default: false, + }, +}; + +// ------------------------------------------------------------------------------------------- +// ------------------------ Derived from the above information ------------------------------- +// ------------------------------------------------------------------------------------------- + +const dependents = Object.entries(questions).reduce((acc, [name, info]) => { + acc[name] = []; + + if (info.dependencies) { + info.dependencies.forEach((dep) => { + if (!acc[dep]) acc[dep] = []; + acc[dep].push(name); + }); + } + return acc; +}, {}); + +const projectWorkflowSchema = { + type: "object", + properties: Object.entries(questions).reduce((acc, [name, info]) => { + acc[name] = info; + return acc; + }, {}), +}; + +// ---------------------------------------------------------------------- +// ------------------------ Preform Class ------------------------------- +// ---------------------------------------------------------------------- + +export class GuidedPreform extends Page { + constructor(...args) { + super(...args); + this.updateForm(); // Register nested pages on creation—not just with data + } + + state = {}; + + header = { + subtitle: "Answer the following questions to simplify your workflow through the GUIDE", + }; + + footer = { + onNext: async () => { + await this.form.validate(); + this.info.globalState.project.workflow = this.state; + this.save(); + return this.to(1); + }, + }; + + updateForm = () => { + const schema = structuredClone(projectWorkflowSchema); + const projectState = this.info.globalState.project ?? {}; + if (!projectState.workflow) projectState.workflow = {}; + this.state = structuredClone(projectState.workflow); + + this.form = new JSONSchemaForm({ + schema, + results: this.state, + validateOnChange: function (name, parent, path, value) { + dependents[name].forEach((dependent) => { + const dependencies = questions[dependent].dependencies; + const dependentEl = this.inputs[dependent]; + if (dependencies.every((dep) => parent[dep])) dependentEl.removeAttribute("disabled"); + else { + dependentEl.updateData(false); + dependentEl.setAttribute("disabled", true); + } + }); + }, + onUpdate: () => (this.unsavedUpdates = true), + onThrow, + groups: [ + { + name: "Session Workflow", + properties: [["multiple_sessions"], ["locate_data"]], + }, + ], + }); + + return this.form; + }; + + render() { + this.state = {}; // Clear local state on each render + + const form = this.updateForm(); + form.style.width = "100%"; + + return html` ${form} `; + } +} + +customElements.get("nwbguide-guided-preform-page") || + customElements.define("nwbguide-guided-preform-page", GuidedPreform); diff --git a/src/renderer/src/stories/pages/settings/SettingsPage.js b/src/renderer/src/stories/pages/settings/SettingsPage.js index f114e33be..6a210216b 100644 --- a/src/renderer/src/stories/pages/settings/SettingsPage.js +++ b/src/renderer/src/stories/pages/settings/SettingsPage.js @@ -66,10 +66,7 @@ function saveNewPipelineFromYaml(name, sourceData, rootFolder) { return acc; }, {}), - structure: { - keep_existing_data: true, - state: false, - }, + structure: {}, results: { [subjectId]: sessions.reduce((acc, sessionId) => { diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 66ea5650b..ef0851cd2 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -16,7 +16,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const screenshotPath = join(__dirname, 'screenshots') const guideRootPath = join(homedir(), paths.root) const testRootPath = join(guideRootPath, '.test') -const testDataPath = join(testRootPath, 'test-data') +const testDataRootPath = join(testRootPath, 'test-data') +const testDataPath = join(testDataRootPath, 'data') +const testDatasetPath = join(testDataRootPath, 'dataset') const alwaysDelete = [ join(testRootPath, 'pipelines'), @@ -31,15 +33,39 @@ const alwaysDelete = [ // ----------------------------------------------------------------------- const testInterfaceInfo = { - SpikeGLXRecordingInterface: { - format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_g0/{subject_id}_{session_id}_g0_imec0/{subject_id}_{session_id}_g0_t0.imec0.ap.bin' + common: { + SpikeGLXRecordingInterface: { + id: 'SpikeGLX Recording', + }, + PhySortingInterface: { + id: 'Phy Sorting' + } }, - PhySortingInterface: { - format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_phy' + multi: { + SpikeGLXRecordingInterface: { + format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_g0/{subject_id}_{session_id}_g0_imec0/{subject_id}_{session_id}_g0_t0.imec0.ap.bin' + }, + PhySortingInterface: { + format: '{subject_id}/{subject_id}_{session_id}/{subject_id}_{session_id}_phy' + } + }, + single: { + SpikeGLXRecordingInterface: { + file_path: join(testDataPath, 'spikeglx', 'Session1_g0', 'Session1_g0_imec0', 'Session1_g0_t0.imec0.ap.bin') + }, + PhySortingInterface: { + folder_path: join(testDataPath, 'phy') + } } } -const regenerateTestData = !existsSync(testDataPath) || false // Generate only if doesn't exist +const subjectInfo = { + sex: 'M', + species: 'Mus musculus', + age: 'P30D' +} + +const regenerateTestData = !existsSync(testDataRootPath) || false // Generate only if doesn't exist const dandiInfo = { id: '212750', @@ -57,7 +83,7 @@ if (skipUpload) console.log('No DANDI API key provided. Will skip upload step... beforeAll(() => { if (regenerateTestData) { - if (existsSync(testDataPath)) rmSync(testDataPath, { recursive: true }) + if (existsSync(testDataRootPath)) rmSync(testDataRootPath, { recursive: true }) } alwaysDelete.forEach(path => existsSync(path) ? rmSync(path, { recursive: true }) : '') @@ -183,7 +209,15 @@ describe('E2E Test', () => { await takeScreenshot('valid-name', 300) // Advance to formats page - await toNextPage('structure') + await toNextPage('workflow') + + }) + + test('View the pre-form workflow page', async () => { + + await takeScreenshot('workflow-page', 300) + await toNextPage('structure') + }) @@ -211,38 +245,36 @@ describe('E2E Test', () => { const dashboard = document.querySelector('nwb-dashboard') const page = dashboard.page const [name, info] = Object.entries(interfaces)[0] - page.list.add({ key: name, value: name }); + page.list.add({ key: info.id, value: name }); page.searchModal.toggle(false); - }, testInterfaceInfo) + }, testInterfaceInfo.common) await takeScreenshot('interface-added', 1000) await evaluate((interfaces) => { const dashboard = document.querySelector('nwb-dashboard') const page = dashboard.page - Object.keys(interfaces).slice(1).forEach(name => page.list.add({ key: name, value: name })) - }, testInterfaceInfo) + Object.entries(interfaces).slice(1).forEach(([ name, info ]) => page.list.add({ key: info.id, value: name })) + }, testInterfaceInfo.common) await takeScreenshot('all-interfaces-added') - await toNextPage('locate') + // await toNextPage('locate') + // await toNextPage('subjects') + await toNextPage('sourcedata') }) - test('Locate all your source data programmatically', async () => { - - await takeScreenshot('pathexpansion-page', 300) + // NOTE: Locate data is skipped in single session mode + test.skip('Locate all your source data programmatically', async () => { await evaluate(async () => { const dashboard = document.querySelector('nwb-dashboard') const page = dashboard.page - page.optional.yes.onClick() - Object.values(page.form.accordions).forEach(accordion => accordion.toggle(true)) + }, testInterfaceInfo.multi) - }, testInterfaceInfo) - - await takeScreenshot('pathexpansion-selected') + await takeScreenshot('pathexpansion-page') // Fill out the path expansion information await evaluate((interfaceInfo, basePath) => { @@ -260,8 +292,8 @@ describe('E2E Test', () => { dashboard.main.querySelector('main > section').scrollTop = 200 }, - testInterfaceInfo, - join(testDataPath, 'dataset') + testInterfaceInfo.multi, + testDatasetPath ) @@ -271,7 +303,8 @@ describe('E2E Test', () => { }) - test('Provide subject information', async () => { + // NOTE: Subject information is skipped in single session mode + test.skip('Provide subject information', async () => { await takeScreenshot('subject-page', 300) @@ -303,12 +336,7 @@ describe('E2E Test', () => { const data = { ...table.data } for (let name in data) { - data[name] = { - ...data[name], - sex: 'M', - species: 'Mus musculus', - age: 'P30D' - } + data[name] = { ...data[name], ...subjectInfo } } table.data = data // This changes the render but not the update flag @@ -321,9 +349,42 @@ describe('E2E Test', () => { }) - test('Review source data information', async () => { + // NOTE: This isn't pre-filled in single session mode + test.skip('Review source data information', async () => { + + await takeScreenshot('sourcedata-page', 100) + await toNextPage('metadata') + + }) + + test('Specify source data information', async () => { + + await references.page.evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + Object.values(page.forms[0].form.accordions).forEach(accordion => accordion.toggle(true)) + }) await takeScreenshot('sourcedata-page', 100) + + await references.page.evaluate(({ single, common }) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + + Object.entries(common).forEach(([name, info]) => { + const form = page.forms[0].form.forms[info.id] + + const interfaceInfo = single[name] + for (let key in single[name]) { + const input = form.getFormElement([ key ]) + input.updateData(interfaceInfo[key]) + } + }) + }, testInterfaceInfo) + + await takeScreenshot('sourcedata-page-specified', 100) + + await toNextPage('metadata') }) @@ -338,6 +399,18 @@ describe('E2E Test', () => { page.forms[0].form.accordions["Subject"].toggle(true) }) + // Update for single session + await evaluate((subjectInfo) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const form = page.forms[0].form.forms['Subject'] + + for (let key in subjectInfo) { + const input = form.getFormElement([ key ]) + input.updateData(subjectInfo[key]) + } + }, subjectInfo) + await takeScreenshot('metadata-open', 100) await toNextPage('inspect')