diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d996bb15..8cb74ddb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ Every release of Open Data Capture from 2.0.0 onward is recorded here. Each sect commit history when the version changes, and becomes the body of the GitHub release. Releases before 2.0.0 are listed in the [release history](https://github.com/DouglasNeuroinformatics/OpenDataCapture/releases). +## 2.4.0 + +### Features + +- **playground:** surface the API's own error message when an upload fails ([188ad51](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/188ad51f003692d03950d65670c27a23c851732b)) +- **playground:** block bundle upload while the editor reports type errors ([5498dca](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/5498dcabd30f2e86654fe2dd7017ac63168d17a7)) + +### Bug Fixes + +- **playground:** match preview mode help tooltip to libui tooltip styling ([fa58149](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/fa58149996c2d6300402ce33282412a1c0c0cd2f)) +- **playground:** replace inner Radix tooltip with CSS-only tooltip in ShareButton ([746db5e](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/746db5ed361f5a8028138cce05c0627e23f7e2ae)) + ## 2.3.1 ### Bug Fixes diff --git a/apps/playground/AGENTS.md b/apps/playground/AGENTS.md index 25ffd6632..353851090 100644 --- a/apps/playground/AGENTS.md +++ b/apps/playground/AGENTS.md @@ -93,6 +93,14 @@ only caller, so tightening it silently breaks upload here and nowhere else (#139 narrower because a bundle is evaluated server-side: whoever may create an instrument can already run code on the API. `testing/src/specs/authorization.spec.ts` pins the contract. +Upload is gated on monaco's own diagnostics: `useEditorErrorSync` writes every error-severity marker +owned by the `typescript` or `javascript` language into `editorErrors`, and `UploadBundleDialog` +refuses to post while that list is non-empty. The transpiler state is not that gate — esbuild strips +types without checking them, so an instrument that fails `tsc` still reaches `status: 'built'`. The +hook asks for markers file by file, because monaco also holds models for the runtime declarations, +`globals.d.ts` and files the user has deleted (`deleteFile` never disposes a model), none of which +should block an upload. + Share links come from `@opendatacapture/playground-url`, which lz-string-compresses file contents into the URL fragment. A link that differs only in its fragment does not reload the page, so `IndexPage` reads the URL through `useLocationHref`, which re-renders on `hashchange`; reading `location.href` diff --git a/apps/playground/src/components/Editor/EditorPane.tsx b/apps/playground/src/components/Editor/EditorPane.tsx index 4d45e5f19..830e2986b 100644 --- a/apps/playground/src/components/Editor/EditorPane.tsx +++ b/apps/playground/src/components/Editor/EditorPane.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { useTheme, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import MonacoEditor from '@monaco-editor/react'; +import { useEditorErrorSync } from '@/hooks/useEditorErrorSync'; import { useFilesRef } from '@/hooks/useFilesRef'; import { useRuntime } from '@/hooks/useRuntime'; import type { EditorFile } from '@/models/editor-file.model'; @@ -38,6 +39,8 @@ export const EditorPane = React.forwardRef(funct const [defaultFile, setDefaultFile] = useState<(EditorFile & { id: string }) | null>(null); const filesRef = useFilesRef(); + useEditorErrorSync(isMounted ? monacoRef.current : null); + useImperativeHandle( ref, () => ({ diff --git a/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx b/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx index 32b8d9b45..00d1e515f 100644 --- a/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx +++ b/apps/playground/src/components/Header/ActionsDropdown/UploadBundleDialog.tsx @@ -6,9 +6,24 @@ import { Button, Dialog } from '@douglasneuroinformatics/libui/components'; import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; import axios, { isAxiosError } from 'axios'; import type { AxiosResponse } from 'axios'; +import { z } from 'zod/v4'; import { useAppStore } from '@/store'; +const MAX_DISPLAYED_ERRORS = 5; + +const $ErrorResponseBody = z.object({ + message: z.union([z.string(), z.array(z.string())]) +}); + +const getResponseErrorMessage = (data: unknown): string | undefined => { + const result = $ErrorResponseBody.safeParse(data); + if (!result.success) { + return undefined; + } + return Array.isArray(result.data.message) ? result.data.message.join(', ') : result.data.message; +}; + export type UploadBundleDialogProps = { isOpen: boolean; onLoginRequired: () => void; @@ -20,6 +35,7 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa const auth = useAppStore((store) => store.auth); const apiBaseUrl = useAppStore((store) => store.settings.apiBaseUrl); + const editorErrors = useAppStore((store) => store.editorErrors); const revalidateToken = useAppStore((store) => store.revalidateToken); const transpilerStateRef = useRef(useAppStore.getState().transpilerState); @@ -40,7 +56,16 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa const handleSubmit = async () => { const state = transpilerStateRef.current; - if (state.status === 'building' || state.status === 'initial') { + if (editorErrors.length > 0) { + addNotification({ + message: t({ + en: 'Upload Failed: Type Errors', + fr: 'Échec du téléversement : Erreurs de type' + }), + type: 'error' + }); + return; + } else if (state.status === 'building' || state.status === 'initial') { addNotification({ message: t({ en: 'Upload Failed: Transpilation Incomplete', @@ -82,16 +107,19 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa ); } catch (err) { console.error(err); - let message: string; - if (isAxiosError(err)) { - message = err.response ? `${err.response.status} ${err.response.statusText}` : err.message; - } else { - message = 'Unknown Error'; + const response = isAxiosError(err) ? err.response : undefined; + if (!response) { + addNotification({ + message: isAxiosError(err) ? err.message : 'Unknown Error', + type: 'error', + title: t({ en: 'HTTP Request Failed', fr: 'Échec de la requête HTTP' }) + }); + return; } addNotification({ - message, + message: getResponseErrorMessage(response.data), type: 'error', - title: t({ en: 'HTTP Request Failed', fr: 'Échec de la requête HTTP' }) + title: [response.status, response.statusText].filter(Boolean).join(' - ') }); return; } @@ -124,7 +152,39 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa {t({ en: ' to upload a bundle.', fr: ' pour téléverser un paquet.' })}

)} - diff --git a/apps/playground/src/hooks/useEditorErrorSync.ts b/apps/playground/src/hooks/useEditorErrorSync.ts new file mode 100644 index 000000000..b32940be8 --- /dev/null +++ b/apps/playground/src/hooks/useEditorErrorSync.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; + +import type { MonacoType } from '@/components/Editor/types'; +import { useAppStore } from '@/store'; +import type { EditorError } from '@/store/types'; + +const TYPE_CHECKED_MARKER_OWNERS: string[] = ['javascript', 'typescript']; + +/** + * Markers are keyed by model, and monaco holds models the instrument does not own: the runtime + * declarations, `globals.d.ts`, and the models of files the user has since deleted, which are never + * disposed. Asking file by file is what keeps those out of the result. + */ +const collectEditorErrors = (monaco: MonacoType, filenames: string[]): EditorError[] => { + return filenames.flatMap((filename) => { + return monaco.editor + .getModelMarkers({ resource: monaco.Uri.parse(filename) }) + .filter( + (marker) => marker.severity === monaco.MarkerSeverity.Error && TYPE_CHECKED_MARKER_OWNERS.includes(marker.owner) + ) + .map((marker) => ({ filename, line: marker.startLineNumber, message: marker.message })); + }); +}; + +export function useEditorErrorSync(monaco: MonacoType | null) { + const setEditorErrors = useAppStore((store) => store.setEditorErrors); + useEffect(() => { + if (!monaco) { + return; + } + const refresh = () => { + const filenames = useAppStore.getState().files.map((file) => file.name); + setEditorErrors(collectEditorErrors(monaco, filenames)); + }; + refresh(); + const markersListener = monaco.editor.onDidChangeMarkers(refresh); + const unsubscribeFromFiles = useAppStore.subscribe((store) => store.files, refresh); + return () => { + markersListener.dispose(); + unsubscribeFromFiles(); + setEditorErrors([]); + }; + }, [monaco]); +} diff --git a/apps/playground/src/store/index.ts b/apps/playground/src/store/index.ts index 95c45bcd1..4d497fc8e 100644 --- a/apps/playground/src/store/index.ts +++ b/apps/playground/src/store/index.ts @@ -10,6 +10,7 @@ import { immer } from 'zustand/middleware/immer'; import { resolveIndexFilename } from '@/utils/file'; import { createAuthSlice } from './slices/auth.slice'; +import { createDiagnosticsSlice } from './slices/diagnostics.slice'; import { createEditorSlice } from './slices/editor.slice'; import { createInstrumentSlice } from './slices/instrument.slice'; import { createSettingsSlice } from './slices/settings.slice'; @@ -36,6 +37,7 @@ export const useAppStore = create( subscribeWithSelector( immer((...a) => ({ ...createAuthSlice(...a), + ...createDiagnosticsSlice(...a), ...createEditorSlice(...a), ...createInstrumentSlice(...a), ...createSettingsSlice(...a), diff --git a/apps/playground/src/store/slices/diagnostics.slice.ts b/apps/playground/src/store/slices/diagnostics.slice.ts new file mode 100644 index 000000000..ff7abcc79 --- /dev/null +++ b/apps/playground/src/store/slices/diagnostics.slice.ts @@ -0,0 +1,10 @@ +import type { DiagnosticsSlice, SliceCreator } from '../types'; + +export const createDiagnosticsSlice: SliceCreator = (set) => ({ + editorErrors: [], + setEditorErrors: (editorErrors) => { + set((state) => { + state.editorErrors = editorErrors; + }); + } +}); diff --git a/apps/playground/src/store/types.ts b/apps/playground/src/store/types.ts index 5bc9fa6f6..c19a91be6 100644 --- a/apps/playground/src/store/types.ts +++ b/apps/playground/src/store/types.ts @@ -44,6 +44,17 @@ export type AuthSlice = { revalidateToken: () => void; }; +export type EditorError = { + filename: string; + line: number; + message: string; +}; + +export type DiagnosticsSlice = { + editorErrors: EditorError[]; + setEditorErrors: (editorErrors: EditorError[]) => void; +}; + export type EditorState = { files: EditorFile[]; indexFilename: null | string; @@ -87,7 +98,13 @@ export type ViewerSlice = { }; }; -export type AppStore = AuthSlice & EditorSlice & InstrumentSlice & SettingsSlice & TranspilerSlice & ViewerSlice; +export type AppStore = AuthSlice & + DiagnosticsSlice & + EditorSlice & + InstrumentSlice & + SettingsSlice & + TranspilerSlice & + ViewerSlice; export type SliceCreator = StateCreator< AppStore, diff --git a/docs/en/6-changelog/changelog.md b/docs/en/6-changelog/changelog.md index b20888f55..5bfed22cc 100644 --- a/docs/en/6-changelog/changelog.md +++ b/docs/en/6-changelog/changelog.md @@ -7,6 +7,18 @@ Every release of Open Data Capture from 2.0.0 onward is recorded here. Each sect commit history when the version changes, and becomes the body of the GitHub release. Releases before 2.0.0 are listed in the [release history](https://github.com/DouglasNeuroinformatics/OpenDataCapture/releases). +## 2.4.0 + +### Features + +- **playground:** surface the API's own error message when an upload fails ([188ad51](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/188ad51f003692d03950d65670c27a23c851732b)) +- **playground:** block bundle upload while the editor reports type errors ([5498dca](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/5498dcabd30f2e86654fe2dd7017ac63168d17a7)) + +### Bug Fixes + +- **playground:** match preview mode help tooltip to libui tooltip styling ([fa58149](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/fa58149996c2d6300402ce33282412a1c0c0cd2f)) +- **playground:** replace inner Radix tooltip with CSS-only tooltip in ShareButton ([746db5e](https://github.com/DouglasNeuroinformatics/OpenDataCapture/commit/746db5ed361f5a8028138cce05c0627e23f7e2ae)) + ## 2.3.1 ### Bug Fixes diff --git a/package.json b/package.json index 1fb48d355..71de39c5f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "opendatacapture", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "private": true, "packageManager": "pnpm@10.34.3", "license": "Apache-2.0", diff --git a/packages/instrument-bundler/package.json b/packages/instrument-bundler/package.json index 09373c806..649be1de8 100644 --- a/packages/instrument-bundler/package.json +++ b/packages/instrument-bundler/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-bundler", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "sideEffects": [ "**/cli.ts" ], diff --git a/packages/instrument-guidelines/package.json b/packages/instrument-guidelines/package.json index 7c1d6d279..283dcbd38 100644 --- a/packages/instrument-guidelines/package.json +++ b/packages/instrument-guidelines/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/instrument-guidelines", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "description": "Guidelines for authoring Open Data Capture instruments, intended to be read by an AI agent (e.g. Claude Code).", "license": "Apache-2.0", "publishConfig": { diff --git a/packages/playground-url/package.json b/packages/playground-url/package.json index 675f93980..5a94f2789 100644 --- a/packages/playground-url/package.json +++ b/packages/playground-url/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/playground-url", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "sideEffects": [ "**/cli.ts" ], diff --git a/packages/serve-instrument/package.json b/packages/serve-instrument/package.json index aec1f1268..de39980b7 100644 --- a/packages/serve-instrument/package.json +++ b/packages/serve-instrument/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/serve-instrument", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "license": "Apache-2.0", "publishConfig": { "access": "public" diff --git a/runtime/v1/package.json b/runtime/v1/package.json index 1e9891f52..4fcd3bc48 100644 --- a/runtime/v1/package.json +++ b/runtime/v1/package.json @@ -1,7 +1,7 @@ { "name": "@opendatacapture/runtime-v1", "type": "module", - "version": "2.3.1", + "version": "2.4.0", "author": { "name": "Douglas Neuroinformatics", "email": "support@douglasneuroinformatics.ca"