diff --git a/src/renderer/assets/css/guided.css b/src/renderer/assets/css/guided.css index 518c1c883..0aba13411 100644 --- a/src/renderer/assets/css/guided.css +++ b/src/renderer/assets/css/guided.css @@ -52,6 +52,11 @@ margin-top: 3px; padding-left: 5px; } + +.guided--nav-bar-section-page.skipped { + border-left: 3px solid black !important; +} + .guided--nav-bar-section-page.completed { border-left: 3px solid green; } @@ -60,6 +65,7 @@ pointer-events: none; opacity: 0.5; } + .guided--nav-bar-section-page.active { border-left: 3px solid rgb(0, 133, 210); background-color: var(--color-transparent-soda-green); diff --git a/src/renderer/src/pages.js b/src/renderer/src/pages.js index 55fe531b7..6029ac0a9 100644 --- a/src/renderer/src/pages.js +++ b/src/renderer/src/pages.js @@ -28,6 +28,7 @@ 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(); @@ -132,18 +133,24 @@ const pages = { inspect: new GuidedInspectorPage({ title: "Inspector Report", - label: "Inspect files", + label: "Validate metadata", section: sections[2], sync: ["preview"], }), preview: new GuidedStubPreviewPage({ title: "Conversion Preview", - label: "Preview files", + label: "Preview NWB files", section: sections[2], sync: ["preview"], }), + conversion: new GuidedResultsPage({ + title: "Conversion Review", + label: "Review conversion", + section: sections[2], + }), + upload: new GuidedUploadPage({ title: "DANDI Upload Options", label: "Upload to DANDI", @@ -151,9 +158,9 @@ const pages = { sync: ["conversion"], }), - review: new GuidedResultsPage({ - title: "Conversion Review", - label: "View conversion report", + review: new GuidedDandiResultsPage({ + title: "Upload Review", + label: "Review published data", section: sections[3], }), }, diff --git a/src/renderer/src/stories/Dashboard.js b/src/renderer/src/stories/Dashboard.js index 7606fe64c..f268da814 100644 --- a/src/renderer/src/stories/Dashboard.js +++ b/src/renderer/src/stories/Dashboard.js @@ -1,7 +1,7 @@ import { LitElement, html } from "lit"; import useGlobalStyles from "./utils/useGlobalStyles.js"; -import { Main } from "./Main.js"; +import { Main, checkIfPageIsSkipped } from "./Main.js"; import { Sidebar } from "./sidebar.js"; import { NavigationSidebar } from "./NavigationSidebar.js"; @@ -280,15 +280,7 @@ export class Dashboard extends LitElement { 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; - } + pageState.skipped = checkIfPageIsSkipped(page, globalState.project?.workflow); if (page.info.pages) this.#getSections(page.info.pages, globalState); // Show all states diff --git a/src/renderer/src/stories/JSONSchemaForm.js b/src/renderer/src/stories/JSONSchemaForm.js index 20dce8ec2..50c37818d 100644 --- a/src/renderer/src/stories/JSONSchemaForm.js +++ b/src/renderer/src/stories/JSONSchemaForm.js @@ -508,13 +508,16 @@ export class JSONSchemaForm extends LitElement { const resolvedSchema = e.schema; // Get offending schema // ------------ Exclude Certain Errors ------------ - // Allow for constructing types from object types - if ( - e.message.includes("is not of a type(s)") && - "properties" in resolvedSchema && - resolvedSchema.type === "string" - ) - return; + // Allow referring to floats as null (i.e. JSON NaN representation) + if (e.message.includes("is not of a type(s)")) { + if (resolvedSchema.type === "number") { + if (resolvedValue === "NaN") return; + else if (resolvedValue === null) return; + else if (isRow) e.message = `${e.message}. ${templateNaNMessage}`; + } else if (resolvedSchema.type === "string") { + if ("properties" in resolvedSchema) return; // Allow for constructing types from object types + } + } // Ignore required errors if value is empty if (e.name === "required" && this.validateEmptyValues === null && !(e.property in e.instance)) return; @@ -522,13 +525,6 @@ export class JSONSchemaForm extends LitElement { // Non-Strict Rule if (resolvedSchema.strict === false && e.message.includes("is not one of enum values")) return; - // Allow referring to floats as null (i.e. JSON NaN representation) - if (e.message === "is not of a type(s) number") { - if (resolvedValue === "NaN") return; - else if (resolvedValue === null) { - } else if (isRow) e.message = `${e.message}. ${templateNaNMessage}`; - } - const prevHeader = name ? header(name) : "Row"; return { diff --git a/src/renderer/src/stories/JSONSchemaInput.js b/src/renderer/src/stories/JSONSchemaInput.js index 3d860d6d5..e3d6bb290 100644 --- a/src/renderer/src/stories/JSONSchemaInput.js +++ b/src/renderer/src/stories/JSONSchemaInput.js @@ -871,6 +871,9 @@ export class JSONSchemaInput extends LitElement { const isArray = schema.type === "array"; // Handle string (and related) formats / types + const itemSchema = this.form?.getSchema ? this.form.getSchema("items", schema) : schema["items"]; + const isTable = itemSchema?.type === "object" && this.renderTable; + const canAddProperties = isEditableObject(this.schema, this.value); if (this.renderCustomHTML) { @@ -901,7 +904,7 @@ export class JSONSchemaInput extends LitElement { }; // Transform to single item if maxItems is 1 - if (isArray && schema.maxItems === 1) { + if (isArray && schema.maxItems === 1 && !isTable) { return new JSONSchemaInput({ value: this.value?.[0], schema: { @@ -940,8 +943,6 @@ export class JSONSchemaInput extends LitElement { } } - const itemSchema = this.form?.getSchema ? this.form.getSchema("items", schema) : schema["items"]; - const fileSystemFormat = isFilesystemSelector(name, itemSchema?.format); if (fileSystemFormat) return createFilesystemSelector(fileSystemFormat); // Create tables if possible @@ -1002,7 +1003,7 @@ export class JSONSchemaInput extends LitElement { ${list} `; } - } else if (itemSchema?.type === "object" && this.renderTable) { + } else if (isTable) { const instanceThis = this; function updateFunction(path, value = this.data) { diff --git a/src/renderer/src/stories/Main.js b/src/renderer/src/stories/Main.js index f556cd137..ecad8b810 100644 --- a/src/renderer/src/stories/Main.js +++ b/src/renderer/src/stories/Main.js @@ -3,9 +3,23 @@ import useGlobalStyles from "./utils/useGlobalStyles.js"; import { GuidedFooter } from "./pages/guided-mode/GuidedFooter"; import { GuidedCapsules } from "./pages/guided-mode/GuidedCapsules.js"; import { GuidedHeader } from "./pages/guided-mode/GuidedHeader.js"; - import { unsafeHTML } from "lit/directives/unsafe-html.js"; +export const checkIfPageIsSkipped = (page, workflowValues = {}) => { + if (page.workflow) { + const workflow = page.workflow; + const skipped = Object.entries(workflow).some(([key, state]) => { + const value = workflowValues[key]; + if (state.condition) return state.condition(value) ? state.skip : false; + if (!value) return state.skip; + }); + + return skipped; + } + + return false; +}; + const componentCSS = ` :host { display: grid; @@ -97,6 +111,20 @@ export class Main extends LitElement { section.scrollTop = 0; } + #hasAvailableNextPages = (page) => { + const allNext = []; + let currentPage = page; + const workflowValues = page.info.globalState?.project?.workflow ?? {}; + while (currentPage.info.next) { + const nextPage = currentPage.info.next; + const skipped = checkIfPageIsSkipped(nextPage, workflowValues); + if (!skipped) allNext.push(nextPage); + currentPage = nextPage; + } + + return allNext.length > 0; + }; + render() { let { page = "", sections = {} } = this.toRender ?? {}; @@ -113,8 +141,10 @@ export class Main extends LitElement { if (info.parent) { if (!("footer" in page)) footer = true; // Allow navigating laterally if there is a next page + const hasAvailableNextPages = this.#hasAvailableNextPages(page); + // Go to home screen if there is no next page - if (!info.next) { + if (!info.next || !hasAvailableNextPages) { footer = Object.assign( { exit: false, diff --git a/src/renderer/src/stories/NavigationSidebar.js b/src/renderer/src/stories/NavigationSidebar.js index 168c953fb..fe7d5c120 100644 --- a/src/renderer/src/stories/NavigationSidebar.js +++ b/src/renderer/src/stories/NavigationSidebar.js @@ -129,6 +129,7 @@ export class NavigationSidebar extends LitElement { class=" guided--nav-bar-section-page hidden + ${state.skipped ? " skipped" : ""} ${state.visited && !state.skipped ? " completed" : " not-completed"} ${state.active ? "active" : ""}"f " diff --git a/src/renderer/src/stories/pages/Page.js b/src/renderer/src/stories/pages/Page.js index e535afdde..d674e4044 100644 --- a/src/renderer/src/stories/pages/Page.js +++ b/src/renderer/src/stories/pages/Page.js @@ -114,7 +114,7 @@ export class Page extends LitElement { delete this.info.globalState.results[subject][session]; } - mapSessions = (callback, data = this.info.globalState) => mapSessions(callback, data); + mapSessions = (callback, data = this.info.globalState.results) => mapSessions(callback, data); async convert({ preview } = {}) { const key = preview ? "preview" : "conversion"; diff --git a/src/renderer/src/stories/pages/globals.js b/src/renderer/src/stories/pages/globals.js index 4d16a4115..bab8d4df2 100644 --- a/src/renderer/src/stories/pages/globals.js +++ b/src/renderer/src/stories/pages/globals.js @@ -1 +1 @@ -export const sections = ["Project Structure", "Data Entry", "Conversion Preview", "Upload & Review"]; +export const sections = ["Project Structure", "Data Entry", "File Conversion", "Dataset Publication"]; 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 a6231e903..4b5975e8d 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedMetadata.js @@ -443,7 +443,7 @@ export class GuidedMetadataPage extends ManagedPage { this.localState = { results: structuredClone(this.info.globalState.results ?? {}) }; - this.forms = this.mapSessions(this.createForm, this.localState); + this.forms = this.mapSessions(this.createForm, this.localState.results); let instances = {}; this.forms.forEach(({ subject, session, 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 2d67ff619..438520b7b 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedSourceData.js @@ -227,7 +227,7 @@ export class GuidedSourceDataPage extends ManagedPage { render() { this.localState = { results: structuredClone(this.info.globalState.results ?? {}) }; - this.forms = this.mapSessions(this.createForm, this.localState); + this.forms = this.mapSessions(this.createForm, this.localState.results); let instances = {}; this.forms.forEach(({ subject, session, form }) => { diff --git a/src/renderer/src/stories/pages/guided-mode/options/GuidedUpload.js b/src/renderer/src/stories/pages/guided-mode/options/GuidedUpload.js index 7991f9991..fa683f08d 100644 --- a/src/renderer/src/stories/pages/guided-mode/options/GuidedUpload.js +++ b/src/renderer/src/stories/pages/guided-mode/options/GuidedUpload.js @@ -48,6 +48,13 @@ export class GuidedUploadPage extends Page { ], }; + workflow = { + upload_to_dandi: { + condition: (v) => v === false, + skip: true, + }, + }; + globalModal = null; #saveNotification; diff --git a/src/renderer/src/stories/pages/guided-mode/results/GuidedDandiResults.js b/src/renderer/src/stories/pages/guided-mode/results/GuidedDandiResults.js new file mode 100644 index 000000000..26e3c3209 --- /dev/null +++ b/src/renderer/src/stories/pages/guided-mode/results/GuidedDandiResults.js @@ -0,0 +1,47 @@ +import { html } from "lit"; +import { Page } from "../../Page.js"; + +import { DandiResults } from "../../../DandiResults"; + +export class GuidedDandiResultsPage extends Page { + constructor(...args) { + super(...args); + } + + footer = {}; + + workflow = { + upload_to_dandi: { + condition: (v) => v === false, + skip: true, + }, + }; + + updated() { + this.save(); // Save the current state + } + + render() { + const { conversion } = this.info.globalState; + + if (!conversion) + return html`

Your conversion failed. Please try again.

`; + + const { info = {}, results } = this.info.globalState.upload ?? {}; + const { dandiset } = info; + + return html`
+ ${new DandiResults({ + id: dandiset, + files: { + subject: results.map((file) => { + return { file }; + }), + }, + })} +
`; + } +} + +customElements.get("nwbguide-guided-dandi-results-page") || + customElements.define("nwbguide-guided-dandi-results-page", GuidedDandiResultsPage); diff --git a/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js b/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js index 62ab74f6d..d19cba5cf 100644 --- a/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js +++ b/src/renderer/src/stories/pages/guided-mode/results/GuidedResults.js @@ -1,8 +1,6 @@ import { html } from "lit"; import { Page } from "../../Page.js"; -import { DandiResults } from "../../../DandiResults.js"; - export class GuidedResultsPage extends Page { constructor(...args) { super(...args); @@ -20,19 +18,19 @@ export class GuidedResultsPage extends Page { if (!conversion) return html`

Your conversion failed. Please try again.

`; - const { info = {}, results } = this.info.globalState.upload ?? {}; - const { dandiset } = info; - - return html`
- ${new DandiResults({ - id: dandiset, - files: { - subject: results.map((file) => { - return { file }; - }), - }, - })} -
`; + return html` +

Your data was successfully converted to NWB!

+ ${Object.entries(conversion) + .map(([subject, sessions]) => { + return html`

sub-${subject}

+
    + ${Object.entries(sessions).map(([session, info]) => { + return html`
  1. ses-${session} — ${info.file}
  2. `; + })} +
`; + }) + .flat()} + `; } } diff --git a/src/renderer/src/stories/pages/guided-mode/setup/Preform.js b/src/renderer/src/stories/pages/guided-mode/setup/Preform.js index caeb15823..9b9c5e038 100644 --- a/src/renderer/src/stories/pages/guided-mode/setup/Preform.js +++ b/src/renderer/src/stories/pages/guided-mode/setup/Preform.js @@ -43,6 +43,11 @@ const questions = { }, default: false, }, + upload_to_dandi: { + type: "boolean", + title: "Would you like to upload your data to DANDI?", + default: true, + }, }; // ------------------------------------------------------------------------------------------- diff --git a/src/renderer/src/stories/pages/utils.js b/src/renderer/src/stories/pages/utils.js index 492de2fb9..9df6ae61d 100644 --- a/src/renderer/src/stories/pages/utils.js +++ b/src/renderer/src/stories/pages/utils.js @@ -63,8 +63,8 @@ export function merge(toMerge = {}, target = {}, mergeOptions = {}) { return target; } -export function mapSessions(callback = (value) => value, globalState) { - return Object.entries(globalState.results) +export function mapSessions(callback = (value) => value, toIterate = {}) { + return Object.entries(toIterate) .map(([subject, sessions]) => { return Object.entries(sessions).map(([session, info], i) => callback({ subject, session, info }, i)); }) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 9d59f3313..9564b3878 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -230,8 +230,6 @@ describe('E2E Test', () => { await toNextPage('structure') - - }) test('Specify data formats', async () => { @@ -438,13 +436,17 @@ describe('E2E Test', () => { }) test('Review Neurosift visualization', async () => { - await takeScreenshot('preview-page', 1000) // Finish loading Neurosift - await toNextPage('upload') + await toNextPage('conversion') + }) + + test('View the conversion results', async () => { + await takeScreenshot('conversion-results-page', 1000) + await toNextPage('upload') if (skipUpload) await toHome() - }) // Wait for full conversion to complete + }) const uploadDescribe = skipUpload ? describe.skip: describe diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 650872e2c..bbb2502c4 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -31,7 +31,7 @@ describe('metadata is specified correctly', () => { // Allow mouse (full list populated from server) baseMetadataSchema.properties.Subject.properties.species.enum = ['Mus musculus'] - const result = mapSessions(info => createResults(info, globalState), globalState) + const result = mapSessions(info => createResults(info, globalState), globalState.results) const res = validator.validate(result[0], baseMetadataSchema) // Check first session with JSON Schema expect(res.errors).toEqual([]) }) @@ -56,91 +56,6 @@ test('removing all existing sessions will maintain the related subject entry on }) -// TODO: Convert an integration -test('inter-table updates are triggered', async () => { - - const results = { - Ecephys: { // NOTE: This layer is required to place the properties at the right level for the hardcoded validation function - ElectrodeGroup: [{ name: 's1' }], - Electrodes: [{ group_name: 's1' }] - } - } - - const schema = { - properties: { - Ecephys: { - properties: { - ElectrodeGroup: { - type: "array", - items: { - required: ["name"], - properties: { - name: { - type: "string" - }, - }, - type: "object", - }, - }, - Electrodes: { - type: "array", - items: { - type: "object", - properties: { - group_name: { - type: "string", - }, - }, - } - }, - } - } - } - } - - - - // Add invalid electrode - const randomStringId = Math.random().toString(36).substring(7) - results.Ecephys.Electrodes.push({ group_name: randomStringId }) - - // Create the form - const form = new JSONSchemaForm({ - schema, - results, - validateOnChange, - renderTable: (name, metadata, path) => { - if (name !== "Electrodes") return new SimpleTable(metadata); - else return true - }, - }) - - document.body.append(form) - - await form.rendered - - // Validate that the results are incorrect - const errors = await form.validate().catch(() => true).catch(() => true) - expect(errors).toBe(true) // Is invalid - - // Update the table with the missing electrode group - const table = form.getFormElement(['Ecephys', 'ElectrodeGroup']) // This is a SimpleTable where rows can be added - const row = table.addRow() - - const baseRow = table.getRow(0) - row.forEach((cell, i) => { - if (cell.simpleTableInfo.col === 'name') cell.setInput(randomStringId) // Set name to random string id - else cell.setInput(baseRow[i].value) // Otherwise carry over info - }) - - // Wait a second for new row values to resolve as table data (async) - await new Promise((res) => setTimeout(() => res(true), 1000)) - - // Validate that the new structure is correct - const hasErrors = await form.validate().then(() => false).catch((e) => true) - expect(hasErrors).toBe(false) // Is valid -}) - const popupSchemas = { "type": "object", "required": ["keywords", "experimenter"], @@ -222,7 +137,6 @@ test('pop-up inputs work correctly', async () => { expect(hasErrors).toBe(false) // Is valid }) - // TODO: Convert an integration test('inter-table updates are triggered', async () => {