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.' })}
)}
-