diff --git a/package-lock.json b/package-lock.json index fd3448426..e5bbb774e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "nwb-guide", - "version": "0.0.16", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nwb-guide", - "version": "0.0.16", + "version": "1.0.0", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -16,7 +16,7 @@ "@vitest/coverage-v8": "^1.6.0", "chokidar": "^3.5.3", "concurrently": "^7.6.0", - "dandi": "^0.0.4", + "dandi": "^0.0.6", "find-free-port": "^2.0.0", "fomantic-ui": "^2.8.8", "fs-extra": "^10.0.0", @@ -8755,10 +8755,9 @@ } }, "node_modules/dandi": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/dandi/-/dandi-0.0.4.tgz", - "integrity": "sha512-W0eM84uV87cCrfr9L3xKATbUYEITfCgG14Pgj9eEx1B1DS+lW/IRLmKHsWp8Noz9vegN7nDwfs43P1BCmZNWsg==", - "license": "MIT" + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/dandi/-/dandi-0.0.6.tgz", + "integrity": "sha512-SiPqaFl7M/MYhWRdZQuAHu/8jgcgZYTMDg3X0IbplCyvWVO+yPVTCZmkY1PX2hPrz3hIkCgNVmYlL6IzNlBq2Q==" }, "node_modules/data-uri-to-buffer": { "version": "6.0.1", @@ -20971,6 +20970,8 @@ }, "node_modules/through2-filter": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", + "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", "license": "MIT", "dependencies": { "through2": "~2.0.0", @@ -20979,6 +20980,8 @@ }, "node_modules/through2-filter/node_modules/through2": { "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", "license": "MIT", "dependencies": { "readable-stream": "~2.3.6", diff --git a/package.json b/package.json index b63394d92..23e6e7005 100644 --- a/package.json +++ b/package.json @@ -145,7 +145,7 @@ "@vitest/coverage-v8": "^1.6.0", "chokidar": "^3.5.3", "concurrently": "^7.6.0", - "dandi": "^0.0.4", + "dandi": "^0.0.6", "find-free-port": "^2.0.0", "fomantic-ui": "^2.8.8", "fs-extra": "^10.0.0", diff --git a/src/electron/frontend/core/components/DandiResults.js b/src/electron/frontend/core/components/DandiResults.js index 37229cc08..210757647 100644 --- a/src/electron/frontend/core/components/DandiResults.js +++ b/src/electron/frontend/core/components/DandiResults.js @@ -1,7 +1,7 @@ import { LitElement, css, html } from "lit"; import { get } from "dandi"; -import { isStaging } from "./pages/uploads/utils"; +import { isStaging, getAPIKey } from "./pages/uploads/utils"; export class DandiResults extends LitElement { static get styles() { @@ -38,8 +38,14 @@ export class DandiResults extends LitElement { const otherElIds = ["embargo_status"]; - const type = isStaging(this.id) ? "staging" : undefined; - const dandiset = await get(this.id, { type }); + const staging = isStaging(this.id); + const type = staging ? "staging" : undefined; + const api_key = await getAPIKey.call(this, staging); + + const dandiset = await get(this.id, { + type, + token: api_key, + }); otherElIds.forEach((str) => handleClass(str, dandiset)); elIds.forEach((str) => handleClass(str, dandiset.draft_version)); diff --git a/src/electron/frontend/core/components/JSONSchemaForm.js b/src/electron/frontend/core/components/JSONSchemaForm.js index 075627d3e..2a3d9503c 100644 --- a/src/electron/frontend/core/components/JSONSchemaForm.js +++ b/src/electron/frontend/core/components/JSONSchemaForm.js @@ -554,7 +554,32 @@ export class JSONSchemaForm extends LitElement { }; validate = async (resolved = this.resolved) => { - if (this.validateEmptyValues === false) this.validateEmptyValues = true; + if (this.validateEmptyValues === false) { + this.validateEmptyValues = true; + await new Promise((resolve) => setTimeout(resolve, 0)); // Wait for next tick (re-render start) + await this.rendered; // Wait for re-render + } + + // Validate nested forms (skip disabled) + for (let name in this.forms) { + const accordion = this.accordions[name]; + if (!accordion || !accordion.disabled) + await this.forms[name].validate(resolved ? resolved[name] : undefined); // Validate nested forms too + } + + for (let key in this.tables) { + try { + this.tables[key].validate(resolved ? resolved[key] : undefined); // Validate nested tables too + } catch (error) { + const title = this.tables[key].schema.title; + const message = error.message.replace( + "this table", + `the ${header(title ?? [...this.base, key].join("."))} table` + ); + this.throw(message); + break; + } + } // Validate against the entire JSON Schema const copy = structuredClone(resolved); @@ -616,27 +641,6 @@ export class JSONSchemaForm extends LitElement { if (message) this.throw(message); - // Validate nested forms (skip disabled) - for (let name in this.forms) { - const accordion = this.accordions[name]; - if (!accordion || !accordion.disabled) - await this.forms[name].validate(resolved ? resolved[name] : undefined); // Validate nested forms too - } - - for (let key in this.tables) { - try { - this.tables[key].validate(resolved ? resolved[key] : undefined); // Validate nested tables too - } catch (error) { - const title = this.tables[key].schema.title; - const message = error.message.replace( - "this table", - `the ${header(title ?? [...this.base, key].join("."))} table` - ); - this.throw(message); - break; - } - } - return true; }; @@ -1007,8 +1011,19 @@ export class JSONSchemaForm extends LitElement { const groupEl = this.#getGroupElement(externalPath); if (groupEl) { - groupEl.classList[resolvedErrors.length ? "add" : "remove"]("error"); - groupEl.classList[warnings.length ? "add" : "remove"]("warning"); + groupEl.setAttribute(`data-${name}-errors`, updatedErrors.length); + groupEl.setAttribute(`data-${name}-warnings`, updatedWarnings.length); + + const allFormSections = groupEl.querySelectorAll(".form-section"); + const inputs = Array.from(allFormSections).map((section) => section.id); + const allErrors = inputs.reduce((acc, id) => acc + parseInt(groupEl.getAttribute(`data-${id}-errors`)), 0); + const allWarnings = inputs.reduce( + (acc, id) => acc + parseInt(groupEl.getAttribute(`data-${id}-warnings`)), + 0 + ); + + groupEl.classList[allErrors ? "add" : "remove"]("error"); + groupEl.classList[allWarnings ? "add" : "remove"]("warning"); } const clearAllErrors = isValid && updatedErrors.length === 0; diff --git a/src/electron/frontend/core/components/Search.js b/src/electron/frontend/core/components/Search.js index bb7d889de..66a988014 100644 --- a/src/electron/frontend/core/components/Search.js +++ b/src/electron/frontend/core/components/Search.js @@ -35,7 +35,6 @@ export class Search extends LitElement { } #close = () => { - console.log("CLOSING", this.getSelectedOption()); if (this.listMode === "input" && this.getAttribute("interacted") === "true") { this.setAttribute("interacted", false); this.#onSelect(this.getSelectedOption()); diff --git a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js index d35e3e616..50f400bc8 100644 --- a/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js +++ b/src/electron/frontend/core/components/pages/guided-mode/options/GuidedUpload.js @@ -182,7 +182,7 @@ export class GuidedUploadPage extends Page { }, onUpdate: () => (this.unsavedUpdates = true), onThrow, - validateOnChange: validate, + validateOnChange: (...args) => validate.call(this, ...args), })); }) .catch((error) => html`

${error}

`); diff --git a/src/electron/frontend/core/components/pages/uploads/UploadsPage.js b/src/electron/frontend/core/components/pages/uploads/UploadsPage.js index 64cf2557e..b38925645 100644 --- a/src/electron/frontend/core/components/pages/uploads/UploadsPage.js +++ b/src/electron/frontend/core/components/pages/uploads/UploadsPage.js @@ -25,16 +25,13 @@ import { Modal } from "../../Modal"; import { DandiResults } from "../../DandiResults.js"; import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; -import { JSONSchemaInput } from "../../JSONSchemaInput.js"; -import { header } from "../../forms/utils"; - import { validateDANDIApiKey } from "../../../validation/dandi"; import * as dandi from "dandi"; import keyIcon from "../../../../assets/icons/key.svg?raw"; -import { AWARD_VALIDATION_FAIL_MESSAGE, awardNumberValidator, isStaging, validate } from "./utils"; +import { AWARD_VALIDATION_FAIL_MESSAGE, awardNumberValidator, isStaging, validate, getAPIKey } from "./utils"; import { createFormModal } from "../../forms/GlobalFormModal"; export function createDandiset(results = {}) { @@ -72,6 +69,7 @@ export function createDandiset(results = {}) { schema: dandiCreateSchema, results, validateEmptyValues: false, // Only show errors after submission + validateOnChange: async (name, parent) => { const value = parent[name]; @@ -93,6 +91,7 @@ export function createDandiset(results = {}) { { name: "Embargo your Data", properties: [["embargo_status"], ["nih_award_number"]], + link: true, }, ], }); @@ -122,7 +121,8 @@ export function createDandiset(results = {}) { token: api_key, type: staging ? "staging" : undefined, }); - await api.init(); + + await api.authorize(); const metadata = { description: form.resolved.description, @@ -173,83 +173,6 @@ export function createDandiset(results = {}) { }; } -async function getAPIKey(staging = false) { - const whichAPIKey = staging ? "development_api_key" : "main_api_key"; - const DANDI = global.data.DANDI; - let api_key = DANDI?.api_keys?.[whichAPIKey]; - - const errors = await validateDANDIApiKey(api_key, staging); - - const isInvalid = !errors || errors.length; - - if (isInvalid) { - const modal = new Modal({ - header: `${api_key ? "Update" : "Provide"} your ${header(whichAPIKey)}`, - open: true, - onClose: () => modal.remove(), - }); - - const input = new JSONSchemaInput({ - path: [whichAPIKey], - schema: dandiGlobalSchema.properties.api_keys.properties[whichAPIKey], - }); - - input.style.padding = "25px"; - - modal.append(input); - - let notification; - - const notify = (message, type) => { - if (notification) this.dismiss(notification); - return (notification = this.notify(message, type)); - }; - - modal.onClose = async () => notify("The updated DANDI API key was not set", "error"); - - api_key = await new Promise((resolve) => { - const button = new Button({ - label: "Save", - primary: true, - onClick: async () => { - const value = input.value; - if (value) { - const errors = await validateDANDIApiKey(input.value, staging); - if (!errors || !errors.length) { - modal.remove(); - - merge( - { - DANDI: { - api_keys: { - [whichAPIKey]: value, - }, - }, - }, - global.data - ); - - global.save(); - resolve(value); - } else { - notify(errors[0].message, "error"); - return false; - } - } else { - notify("Your DANDI API key was not set", "error"); - } - }, - }); - - modal.footer = button; - - document.body.append(modal); - }); - } - - return api_key; -} - export async function uploadToDandi(info, type = "project" in info ? "project" : "") { const { dandiset } = info; @@ -435,7 +358,7 @@ export class UploadsPage extends Page { error.message = "Please select at least one file or folder to upload."; }, - validateOnChange: validate, + validateOnChange: (...args) => validate.call(this, ...args), })); }) .catch((error) => html`

${error}

`); diff --git a/src/electron/frontend/core/components/pages/uploads/utils.ts b/src/electron/frontend/core/components/pages/uploads/utils.ts index 1cdff2822..f82962ee7 100644 --- a/src/electron/frontend/core/components/pages/uploads/utils.ts +++ b/src/electron/frontend/core/components/pages/uploads/utils.ts @@ -1,7 +1,18 @@ import { get } from "dandi"; -import dandiUploadSchema from "../../../../../../schemas/dandi-upload.schema"; -import { html } from "lit"; +import dandiUploadSchema, { regenerateDandisets } from "../../../../../../schemas/dandi-upload.schema"; + +import { validateDANDIApiKey } from "../../../validation/dandi"; +import { Modal } from "../../Modal"; +import { header } from "../../forms/utils"; + +import { JSONSchemaInput } from "../../JSONSchemaInput"; + +import { Button } from "../../Button.js"; +import { global } from "../../../progress/index.js"; +import { merge } from "../utils"; + +import dandiGlobalSchema from "../../../../../../schemas/json/dandi/global.json"; export const isStaging = (id: string) => parseInt(id) >= 100000; @@ -13,7 +24,7 @@ function isNumeric(str: string) { } - export const validate = async (name: string, parent: any) => { + export async function validate (name: string, parent: any) { const value = parent[name] @@ -33,8 +44,13 @@ function isNumeric(str: string) { }] const staging = isStaging(value) + const type = staging ? "staging" : undefined; + const token = await getAPIKey.call(this, staging); - const dandiset = await get(value, { type: staging ? "staging" : undefined }) + const dandiset = await get(value, { + type, + token + }) if (dandiset.detail) { if (dandiset.detail.includes('Not found')) return [{ @@ -44,12 +60,13 @@ function isNumeric(str: string) { if (dandiset.detail.includes('credentials were not provided')) return [{ type: 'warning', - message: `Authentication error – This Dandiset does not appear to be linked to your account.` + message: `Authentication error – This is an embargoed Dandiset and you haven't provided the correct credentials.` }] } // NOTE: This may not be thrown anymore with the above detail checks const { enum: enumValue } = dandiUploadSchema.properties.dandiset; + if (enumValue && !enumValue.includes(value)) return [{ type: 'error', message: `No Access – This Dandiset does not belong to you.` @@ -77,3 +94,92 @@ export function awardNumberValidator(awardNumber: string): boolean { } export const AWARD_VALIDATION_FAIL_MESSAGE = 'Award number must be properly space-delimited.\n\nExample (exclude quotes):\n"1 R01 CA 123456-01A1"'; + + +// this: +export async function getAPIKey( + // this: Page, + staging = false +) { + + const whichAPIKey = staging ? "development_api_key" : "main_api_key"; + const DANDI = global.data.DANDI; + let api_key = DANDI?.api_keys?.[whichAPIKey]; + + const errors = await validateDANDIApiKey(api_key, staging); + + const isInvalid = !errors || errors.length; + + if (isInvalid) { + const modal = new Modal({ + header: `${api_key ? "Update" : "Provide"} your ${header(whichAPIKey)}`, + open: true, + onClose: () => modal.remove(), + }); + + const container = document.createElement("div"); + + const input = new JSONSchemaInput({ + path: [whichAPIKey], + schema: dandiGlobalSchema.properties.api_keys.properties[whichAPIKey], + }); + + container.append(input); + + container.style.padding = "25px"; + + modal.append(container); + + let notification; + + const notify = (message, type) => { + if (notification) this.dismiss(notification); + return (notification = this.notify(message, type)); + }; + + modal.onClose = async () => notify("The updated DANDI API key was not set", "error"); + + api_key = await new Promise((resolve) => { + const button = new Button({ + label: "Save", + primary: true, + onClick: async () => { + const value = input.value; + if (value) { + const errors = await validateDANDIApiKey(input.value, staging); + if (!errors || !errors.length) { + modal.remove(); + + merge( + { + DANDI: { + api_keys: { + [whichAPIKey]: value, + }, + }, + }, + global.data + ); + + global.save(); + resolve(value); + } else { + notify(errors[0].message, "error"); + return false; + } + } else { + notify("Your DANDI API key was not set", "error"); + } + }, + }); + + modal.footer = button; + + document.body.append(modal); + }); + + await regenerateDandisets() + } + + return api_key; +} diff --git a/src/electron/frontend/core/validation/dandi.ts b/src/electron/frontend/core/validation/dandi.ts index d64d017d8..7829b68f8 100644 --- a/src/electron/frontend/core/validation/dandi.ts +++ b/src/electron/frontend/core/validation/dandi.ts @@ -1,6 +1,7 @@ const dandiAPITokenRegex = /^[a-f0-9]{40}$/; +import { validateToken } from 'dandi' export const validateDANDIApiKey = async (apiKey: string, staging = false) => { if (apiKey) { @@ -9,11 +10,8 @@ export const validateDANDIApiKey = async (apiKey: string, staging = false) => { const authFailedError = {type: 'error', message: `Authorization failed. Make sure you're providing an API key for the ${staging ? 'staging' : 'main'} archive.`} - return fetch(`https://api${staging ? '-staging' : ''}.dandiarchive.org/api/auth/token/`, {headers: {Authorization: `token ${apiKey}`}}) - .then((res) => { - if (!res.ok) return [authFailedError] - return true - }) - .catch(() => [authFailedError]) + const isValid = validateToken({ token: apiKey, type: staging ? 'staging' : undefined }).catch(e => false) + if (!isValid) return [ authFailedError ] + return true } } diff --git a/src/schemas/dandi-create.schema.ts b/src/schemas/dandi-create.schema.ts index 8c44ce447..7b08cd927 100644 --- a/src/schemas/dandi-create.schema.ts +++ b/src/schemas/dandi-create.schema.ts @@ -1,4 +1,4 @@ -import create from './json/dandi/create_no_embargo.json' assert { type: "json" } +import create from './json/dandi/create.json' assert { type: "json" } const schema = structuredClone(create) export default schema diff --git a/src/schemas/dandi-upload.schema.ts b/src/schemas/dandi-upload.schema.ts index 41c2edab2..88cc8257e 100644 --- a/src/schemas/dandi-upload.schema.ts +++ b/src/schemas/dandi-upload.schema.ts @@ -63,7 +63,7 @@ export const updateDandisets = async (main = true) => { if (!token) return [] - return await getMine({ token, type: staging ? 'staging' : undefined }) + return await getMine({ token, type: staging ? 'staging' : undefined }, { embargoed: true }) .then((results) => results ? Promise.all(results.map(addDandiset)) : []) .catch(() => { return [] diff --git a/src/schemas/json/dandi/create_no_embargo.json b/src/schemas/json/dandi/create_no_embargo.json deleted file mode 100644 index 2e5ec3d73..000000000 --- a/src/schemas/json/dandi/create_no_embargo.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "order": [ - "title", - "archive", - "embargo_status", - "description", - "license" - ], - "properties": { - - "title": { - "type": "string", - "description": "Provide a title for this Dandiset. The title will appear in search results and at the top of the home page for this Dandiset, so make it concise and descriptive" - }, - - "archive": { - "type": "string", - "enumLabels": { - "staging": "Development Server", - "main": "Main Archive" - }, - "enum": ["main", "staging"], - "description": "Which DANDI server to upload to.
Note: The Development Server is recommended for developers, or users learning to use DANDI" - }, - - "description": { - "type": "string", - "description": "Provide a description for this Dandiset. This will appear prominently under the title in the home page for this Dandiset." - }, - - "license": { - "type": "array", - "description": "Provide a set of licenses for this Dandiset. Review the individual licenses and select the one that best fits your needs.", - "items": { - "type": "string", - "enumLinks": { - "spdx:CC0-1.0": "https://creativecommons.org/public-domain/cc0/", - "spdx:CC-BY-4.0": "https://creativecommons.org/licenses/by/4.0/deed.en" - }, - "enumKeywords": { - "spdx:CC0-1.0": ["No Rights Reserved"], - "spdx:CC-BY-4.0": ["Attribution 4.0 International"] - }, - "enumLabels": { - "spdx:CC0-1.0": "CC0 1.0", - "spdx:CC-BY-4.0": "CC BY 4.0" - }, - "enum": [ - "spdx:CC0-1.0", - "spdx:CC-BY-4.0" - ] - }, - "maxItems": 1, - "uniqueItems": true, - "strict": true - } - - }, - "required": ["title", "description", "license", "archive"] -} diff --git a/tests/metadata.test.ts b/tests/metadata.test.ts index 4773b4d5e..69b87a9b2 100644 --- a/tests/metadata.test.ts +++ b/tests/metadata.test.ts @@ -215,9 +215,10 @@ test('inter-table updates are triggered', async () => { }) await sleep(1000) // Wait for the ElectrodeGroup table to update properly - form.requestUpdate() // Re-render the form to update the Electrodes table + await form.rendered // Wait for the form to re-render and validate properly + // Validate that the new structure is correct const hasErrors = await form.validate().then(() => false).catch((e) => true)