diff --git a/docs/assets/dandi/api-token-location.png b/docs/assets/dandi/api-token-location.png new file mode 100644 index 000000000..2f4a8bbb7 Binary files /dev/null and b/docs/assets/dandi/api-token-location.png differ diff --git a/docs/assets/tutorials/dandi/api-token-added.png b/docs/assets/tutorials/dandi/api-token-added.png new file mode 100644 index 000000000..b112a804c Binary files /dev/null and b/docs/assets/tutorials/dandi/api-token-added.png differ diff --git a/docs/assets/tutorials/dandi/api-tokens.png b/docs/assets/tutorials/dandi/api-tokens.png new file mode 100644 index 000000000..bba1cdb99 Binary files /dev/null and b/docs/assets/tutorials/dandi/api-tokens.png differ diff --git a/docs/assets/tutorials/dandi/create-dandiset.png b/docs/assets/tutorials/dandi/create-dandiset.png new file mode 100644 index 000000000..74a8e7793 Binary files /dev/null and b/docs/assets/tutorials/dandi/create-dandiset.png differ diff --git a/docs/assets/tutorials/dandi/dandiset-id.png b/docs/assets/tutorials/dandi/dandiset-id.png new file mode 100644 index 000000000..2983fa6d4 Binary files /dev/null and b/docs/assets/tutorials/dandi/dandiset-id.png differ diff --git a/docs/assets/tutorials/dandi/review-page.png b/docs/assets/tutorials/dandi/review-page.png new file mode 100644 index 000000000..d4addbc78 Binary files /dev/null and b/docs/assets/tutorials/dandi/review-page.png differ diff --git a/docs/assets/tutorials/home-page.png b/docs/assets/tutorials/home-page.png index ed34f87ec..3dd840888 100644 Binary files a/docs/assets/tutorials/home-page.png and b/docs/assets/tutorials/home-page.png differ diff --git a/docs/assets/tutorials/multiple/home-page-complete.png b/docs/assets/tutorials/multiple/home-page-complete.png index 0e0863058..785e47709 100644 Binary files a/docs/assets/tutorials/multiple/home-page-complete.png and b/docs/assets/tutorials/multiple/home-page-complete.png differ diff --git a/docs/assets/tutorials/single/intro-page.png b/docs/assets/tutorials/single/intro-page.png index fe0915b3a..2b88e6204 100644 Binary files a/docs/assets/tutorials/single/intro-page.png and b/docs/assets/tutorials/single/intro-page.png differ diff --git a/docs/conf.py b/docs/conf.py index c2ab76e33..71360409d 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -16,7 +16,7 @@ "sphinx.ext.intersphinx", # Allows links to other sphinx project documentation sites "sphinx_search.extension", # Allows for auto search function the documentation "sphinx.ext.viewcode", # Shows source code in the documentation - "sphinx.ext.extlinks", # Allows to use shorter external links defined in the extlinks variable. +"sphinx.ext.extlinks", # Allows to use shorter external links defined in the extlinks variable. ] templates_path = ["_templates"] diff --git a/docs/conf_extlinks.py b/docs/conf_extlinks.py index 89228803d..3002178b3 100644 --- a/docs/conf_extlinks.py +++ b/docs/conf_extlinks.py @@ -6,6 +6,8 @@ "matnwb-src": ("https://github.com/NeurodataWithoutBorders/matnwb/%s", "%s"), "nwb-overview": ("https://nwb-overview.readthedocs.io/en/latest/%s", "%s"), "path-expansion-guide": ("https://neuroconv.readthedocs.io/en/main/user_guide/expand_path.html%s", "%s"), + "dandi-staging": ("https://gui-staging.dandiarchive.org/%s", "%s"), + "dandi-archive": ("https://dandiarchive.org/%s", "%s"), "conda-install": ( "https://docs.conda.io/projects/conda/en/latest/user-guide/install/index.html#regular-installation%s", "%s", diff --git a/docs/tutorials/dataset_publication.rst b/docs/tutorials/dataset_publication.rst index db7971565..52d7a8f03 100644 --- a/docs/tutorials/dataset_publication.rst +++ b/docs/tutorials/dataset_publication.rst @@ -1,3 +1,80 @@ Dataset Publication ======================================= -Coming soon... + +For this tutorial, we'll be adapting the previous :doc:`Multi-Session Tutorial ` to publish our data to the DANDI Archive. + +.. note:: + This tutorial focuses on uploading to the Staging server. + + **When working with real data, you'll want to publish to the Main Archive**. In this case, follow the same steps outlined here—except replace the Staging server with the Main Archive. + +.. note:: + Gaining access to DANDI requires approval from the archive administrators. Separate approval is required for both the main archive and the staging server. + + **This tutorial requires an account on the** :dandi-staging:`DANDI staging server <>`. + + We’re going to use the Staging server for this tutorial so we don’t crowd the main DANDI Archive with `synthetic` datasets! However, you’ll want to publish your `real` data on the main server—which will require a separate approval process. + + Once you receive notice that your account was approved, you can move on to the next steps. + +Workflow Setup +-------------- +1. Resume the conversion via the **Convert** page + +2. Navigate to the **Workflow** page. + + a. Specify that you’d like to publish your data to the :dandi-archive:`DANDI Archive <>`. + +3. Navigate back to the **Conversion Review** page + +You'll now notice that the **Exit Pipeline** button has been replaced with **Next**, allowing you to move forward with publication on the DANDI Archive. + +DANDI Upload +------------ +You’ll need to specify your DANDI API keys if you haven’t uploaded from the GUIDE before. These keys are unique between the Main and Staging servers. + +.. figure:: ../assets/tutorials/dandi/api-tokens.png + :align: center + :alt: A pop-up asking for DANDI API keys + +To get your API key, visit the :dandi-staging:`staging website <>` and click on the profile icon in the top-right corner. This will show a dropdown with a copy button, which will assign your API key to the clipboard. + +.. figure:: ../assets/dandi/api-token-location.png + :align: center + :alt: DANDI staging API key added + +Submit this to the Staging API Key input on the GUIDE. + +.. figure:: ../assets/tutorials/dandi/api-token-added.png + :align: center + :alt: DANDI staging API key added + + +Once you have specified your Staging API Key, the **Dandiset** input will allow you to select any existing Dandiset associated with your account by ID (e.g., "207698") or name (e.g., "NWB GUIDE Test"). + +Continue to the next page to trigger your upload to the DANDI Archive. + +.. figure:: ../assets/tutorials/dandi/dandiset-id.png + :align: center + :alt: DANDI upload page with Dandiset ID specified + +Creating a Dandiset from the GUIDE +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +If you do not already own a Dandiset on staging, you will need to create one. Press the Create New Dandiset button to open a pop-up that guides you through the required fields for Dandiset creation. + +.. figure:: ../assets/tutorials/dandi/create-dandiset.png + :align: center + :alt: Dandiset creation pop-up + + +Once this pop-up form is submitted, the Dandiset input will now contain your new Dandiset. + +Final Review +------------ +Once your upload to the DANDI Archive is complete, you will be able to review a quick overview of the associated Dandiset and a list of the uploaded files from this pipeline. + +.. figure:: ../assets/tutorials/dandi/review-page.png + :align: center + :alt: DANDI upload review page + +Congratulations on your first upload to the DANDI Archive from the GUIDE! diff --git a/src/renderer/src/stories/List.ts b/src/renderer/src/stories/List.ts index 11b9a7645..174068320 100644 --- a/src/renderer/src/stories/List.ts +++ b/src/renderer/src/stories/List.ts @@ -156,14 +156,18 @@ export class List extends LitElement { const oldObject = this.object this.#updateObject() - this.onChange({ - items: this.#items, - object: this.object - }, - { - items: oldList, - object: oldObject - }) + if (this.#initialized) { + + this.onChange({ + items: this.#items, + object: this.object + }, + { + items: oldList, + object: oldObject + }) + } + this.requestUpdate('items', oldList) } @@ -177,6 +181,8 @@ export class List extends LitElement { declare listStyles: any + #initialized = false + allowDrop = (ev) => ev.preventDefault(); @@ -230,6 +236,8 @@ export class List extends LitElement { if (props.onChange) this.onChange = props.onChange + this.#initialized = true + } add = (item: ListItemType) => { diff --git a/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js b/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js index c5993c30e..8e70a3f73 100644 --- a/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js +++ b/src/renderer/src/stories/pages/guided-mode/data/GuidedStructure.js @@ -52,10 +52,7 @@ export class GuidedStructurePage extends Page { }, }); - list = new List({ - emptyMessage: defaultEmptyMessage, - onChange: () => (this.unsavedUpdates = "conversions"), - }); + list = new List({ emptyMessage: defaultEmptyMessage }); addButton = new Button(); @@ -146,6 +143,8 @@ export class GuidedStructurePage extends Page { this.list.emptyMessage = defaultEmptyMessage; + const items = []; + for (const [key, name] of Object.entries(interfaces)) { let found = this.search.options?.find((item) => item.value === name); @@ -158,17 +157,27 @@ export class GuidedStructurePage extends Page { }; } - this.list.add({ ...found, key }); // Add previously selected items + items.push({ ...found, key }); } + const ogList = this.list; + + this.list = new List({ + items, + emptyMessage: defaultEmptyMessage, + onChange: () => (this.unsavedUpdates = "conversions"), + }); + + this.list.style.display = "inline-block"; + + ogList.replaceWith(this.list); + this.addButton.removeAttribute("hidden"); super.updated(); // Call if updating data } render() { // Reset list - this.list.style.display = "inline-block"; - this.list.clear(); this.addButton.setAttribute("hidden", ""); return html` diff --git a/tests/e2e/config.ts b/tests/e2e/config.ts index 86d1cb00b..e4471e24a 100644 --- a/tests/e2e/config.ts +++ b/tests/e2e/config.ts @@ -4,7 +4,7 @@ import { homedir } from 'node:os' import { existsSync } from 'node:fs' import paths from "../../paths.config.json" assert { type: "json" }; -import { connect } from '../puppeteer'; +import { connect as connectToElectron } from '../puppeteer'; // ------------------------------------------------------------------ // ------------------------ Path Definitions ------------------------ @@ -119,4 +119,4 @@ export const publish = dandiInfo.token ? true : false if (!publish) console.log('No DANDI API key provided. Will skip dataset publication step...') -export const references = connect() +export const references = connectToElectron() diff --git a/tests/e2e/e2e.test.ts b/tests/e2e/e2e.test.ts index dc8f40327..f7b0f2343 100644 --- a/tests/e2e/e2e.test.ts +++ b/tests/e2e/e2e.test.ts @@ -5,8 +5,19 @@ import { mkdirSync, existsSync, rmSync } from 'node:fs' import { join } from 'node:path' import * as config from './config' -import runWorkflow from './workflow' -import { evaluate, takeScreenshot } from './utils' +import runWorkflow, { uploadToDandi } from './workflow' +import { evaluate, takeScreenshot, to, toNextPage } from './utils' + +const x = 250 // Sidebar size +const width = config.windowDims.width - x + +const datasetScreenshotClip = { + x, + y: 0, + width, + height: 220 +} + beforeAll(() => { @@ -22,8 +33,6 @@ beforeAll(() => { describe('E2E Test', () => { - // NOTE: This is where you should be connecting... - test('Ensure number of test pipelines starts at zero', async () => { await sleep(500) // Wait for full notification to render @@ -34,58 +43,49 @@ describe('E2E Test', () => { expect(nPipelines).toBe(0) }) - describe('Manually run through the pipeline', async () => { - - const datasetTestFunction = config.regenerateTestData ? test : test.skip - datasetTestFunction('Create tutorial dataset', async () => { + const datasetTestFunction = config.regenerateTestData ? test : test.skip - const x = 250 // Sidebar size - const width = config.windowDims.width - x + datasetTestFunction('Create tutorial dataset', async () => { - const screenshotClip = { - x, - y: 0, - width, - height: 220 - } + await evaluate(async () => { - await evaluate(async () => { + // Transition to settings page + const dashboard = document.querySelector('nwb-dashboard') + dashboard.sidebar.select('settings') - // Transition to settings page - const dashboard = document.querySelector('nwb-dashboard') - dashboard.sidebar.select('settings') + // Generate test data + const page = dashboard.page + page.deleteTestData() + }) - // Generate test data - const page = dashboard.page - page.deleteTestData() - }) + await takeScreenshot('dataset-creation', 300, { clip: datasetScreenshotClip }) - await takeScreenshot('dataset-creation', 300, { clip: screenshotClip }) + const outputLocation = await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const outputLocation = await page.generateTestData() + page.requestUpdate() + return outputLocation + }) - const outputLocation = await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const outputLocation = await page.generateTestData() - page.requestUpdate() - return outputLocation - }) + // Take image after dataset generation + await takeScreenshot('dataset-created', 500, { clip: datasetScreenshotClip }) - // Take image after dataset generation - await takeScreenshot('dataset-created', 500, { clip: screenshotClip }) + expect(existsSync(outputLocation)).toBe(true) - expect(existsSync(outputLocation)).toBe(true) + // Navigate back to the home page + let pageId = await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + dashboard.sidebar.select('/') + return dashboard.page.info.id + }) - // Navigate back to the home page - let pageId = await evaluate(() => { - const dashboard = document.querySelector('nwb-dashboard') - dashboard.sidebar.select('/') - return dashboard.page.info.id - }) + expect(pageId).toBe('/') + }) - expect(pageId).toBe('/') - }) + describe('Run through several pipeline workflows', async () => { describe('Complete a single-session workflow', async () => { const subdirectory = 'single' @@ -109,6 +109,58 @@ describe('E2E Test', () => { }) }) + describe('Upload the multi-session output to DANDI', async () => { + + const subdirectory = 'dandi' + + test('Restart pipeline', async () => { + + await evaluate(async () => { + const pipelines = document.getElementById('guided-div-resume-progress-cards').children + const found = Array.from(pipelines).find(card => card.info.project.name === 'Multi Session Workflow') + console.log(found, Array.from(pipelines)) + found.querySelector('button').click() + }) + + }) + + test('Update the workflow to allow DANDI upload', async () => { + + await sleep(1000) + await to('//workflow') + + await evaluate(async ( workflow ) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + + for (let key in workflow) { + const input = page.form.getFormElement([ key ]) + input.updateData(workflow[key]) + } + + page.form.requestUpdate() // Ensure the form is updated visually + + await page.save() + + }, { upload_to_dandi: true }) + + await toNextPage('structure') // Save data without a popup + await to('//conversion') + + // Do not prompt to save + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + page.unsavedUpdates = false + }) + + await to('//upload') // NOTE: It would be nice to avoid having to re-run the conversion... + + }) + + uploadToDandi(subdirectory) // Upload to DANDI if the API key is provided + + }) }) diff --git a/tests/e2e/utils.ts b/tests/e2e/utils.ts index 2f268c84d..6aa6430af 100644 --- a/tests/e2e/utils.ts +++ b/tests/e2e/utils.ts @@ -45,12 +45,13 @@ export const takeScreenshot = async (relativePath, delay = 0, options: Screensho } - export const toHome = async () => { - const pageId = await evaluate(async () => { + export const to = async (pageId = '/') => { + + const outputPageId = await evaluate(async (pageId) => { const dashboard = document.querySelector('nwb-dashboard') - await dashboard.page.to('/') + await dashboard.page.to(pageId) return dashboard.page.info.id - }) + }, pageId) - expect(pageId).toBe('/') // Ensure you are on the home page + expect(outputPageId).toBe(pageId) // Ensure you are on the home page } diff --git a/tests/e2e/workflow.ts b/tests/e2e/workflow.ts index 29e2ef3ed..051e6d383 100644 --- a/tests/e2e/workflow.ts +++ b/tests/e2e/workflow.ts @@ -4,10 +4,84 @@ import { sleep } from '../puppeteer' import { join } from 'node:path' import { evaluate, takeScreenshot, toNextPage } from "./utils" -import { dandiInfo, subjectInfo, testDatasetPath, testInterfaceInfo } from "./config" +import { dandiInfo, publish, subjectInfo, testDatasetPath, testInterfaceInfo } from "./config" +export const uploadToDandi = (subdirectory, forceSkip = false) => { -export default async function runWorkflow (name, workflow, identifier) { + + const uploadDescribe = !publish || forceSkip ? describe.skip : describe + + uploadDescribe('Upload to DANDI', () => { + + test('Upload pipeline output to DANDI', async () => { + + await evaluate(async () => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + await page.rendered + page.click() // Ensure page is clicked (otherwise, Electron crashes after DANDI upload after this...) + }) + + await takeScreenshot(join(subdirectory, 'api-tokens'), 100) + + await evaluate(async (dandiAPIToken) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const modal = page.globalModal + const stagingKeyInput = modal.form.getFormElement(['staging_api_key']) + stagingKeyInput.updateData(dandiAPIToken) + }, dandiInfo.token) + + await takeScreenshot(join(subdirectory, 'api-token-added'), 100) + + + // Open dandiset creation modal + await evaluate(() => { + const dashboard = document.querySelector('nwb-dashboard') + const form = dashboard.page.form + const dandisetInput = form.getFormElement(['dandiset']) + const createDandiset = dandisetInput.controls[0] + createDandiset.onClick() + }) + + await takeScreenshot(join(subdirectory, 'create-dandiset'), 100) + + // Close modal + await evaluate(() => { + const modal = document.querySelector('nwb-modal') as any + modal.toggle(false) + }) + + await evaluate(async (dandisetId) => { + const dashboard = document.querySelector('nwb-dashboard') + const page = dashboard.page + const modal = page.globalModal + await modal.footer.onClick() // Validate and submit value + const idInput = page.form.getFormElement(["dandiset"]) + idInput.updateData(dandisetId) + }, dandiInfo.id) + + await takeScreenshot(join(subdirectory, 'dandiset-id'), 1000) + + await sleep(500) // Wait for input status to update + + await toNextPage('review') + + }, 5 * 60 * 1000) // Wait for upload to finish (~2min on M2). Ensure 5min of possible wait + + + test('Review upload results', async () => { + + await takeScreenshot(join(subdirectory, 'review-page'), 1000) + await toNextPage() + + }) + }) + +} + + +export default async function runWorkflow(name, workflow, identifier) { const willLocateData = workflow.multiple_sessions && workflow.locate_data const willProvideSubjectInfo = workflow.multiple_sessions @@ -48,12 +122,12 @@ export default async function runWorkflow (name, workflow, identifier) { test('View the pre-form workflow page', async () => { - await evaluate(( workflow ) => { + await evaluate((workflow) => { const dashboard = document.querySelector('nwb-dashboard') const page = dashboard.page for (let key in workflow) { - const input = page.form.getFormElement([ key ]) + const input = page.form.getFormElement([key]) input.updateData(workflow[key]) } @@ -227,7 +301,7 @@ export default async function runWorkflow (name, workflow, identifier) { await takeScreenshot(join(identifier, 'subject-error'), 500) - await evaluate(( { common, multiple } ) => { + await evaluate(({ common, multiple }) => { const dashboard = document.querySelector('nwb-dashboard') const page = dashboard.page @@ -239,7 +313,7 @@ export default async function runWorkflow (name, workflow, identifier) { for (let name in data) { - const subjectInfo = { ...common, ...( multiple[name] ?? {} )} + const subjectInfo = { ...common, ...(multiple[name] ?? {}) } data[name] = { ...data[name], ...subjectInfo } } @@ -377,56 +451,6 @@ export default async function runWorkflow (name, workflow, identifier) { else await toNextPage('') }) - const uploadDescribe = workflow.upload_to_dandi ? describe : describe.skip - - uploadDescribe('Upload to DANDI', () => { - - test('Upload pipeline output to DANDI', async () => { - - await takeScreenshot(join('dandi', 'upload-page'), 100) - - await evaluate(async () => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - await page.rendered - page.click() // Ensure page is clicked (otherwise, Electron crashes after DANDI upload after this...) - }) - - await takeScreenshot(join('dandi', 'upload-page-api-tokens'), 100) - - await evaluate(async (dandiAPIToken) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const modal = page.globalModal - const stagingKeyInput = modal.form.getFormElement(['staging_api_key']) - stagingKeyInput.updateData(dandiAPIToken) - }, dandiInfo.token) - - await takeScreenshot(join('dandi', 'upload-page-api-token-added'), 100) - - await evaluate(async (dandisetId) => { - const dashboard = document.querySelector('nwb-dashboard') - const page = dashboard.page - const modal = page.globalModal - await modal.footer.onClick() // Validate and submit value - const idInput = page.form.getFormElement(["dandiset"]) - idInput.updateData(dandisetId) - }, dandiInfo.id) - - await takeScreenshot(join('dandi', 'upload-page-with-id'), 100) - await sleep(500) // Wait for input status to update - - await toNextPage('review') - - }) // Wait for upload to finish (~2min on M2) - - - test('Review upload results', async () => { - - await takeScreenshot(join('dandi', 'review-page'), 1000) - await toNextPage() - - }) - }) + uploadToDandi(identifier, !workflow.upload_to_dandi) }