Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions apps/playground/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
3 changes: 3 additions & 0 deletions apps/playground/src/components/Editor/EditorPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -38,6 +39,8 @@ export const EditorPane = React.forwardRef<EditorPaneRef, EditorPaneProps>(funct
const [defaultFile, setDefaultFile] = useState<(EditorFile & { id: string }) | null>(null);
const filesRef = useFilesRef();

useEditorErrorSync(isMounted ? monacoRef.current : null);

useImperativeHandle(
ref,
() => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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',
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -124,7 +152,39 @@ export const UploadBundleDialog = ({ isOpen, setIsOpen, onLoginRequired }: Uploa
{t({ en: ' to upload a bundle.', fr: ' pour téléverser un paquet.' })}
</p>
)}
<Button disabled={!auth} type="button" onClick={() => void handleSubmit().then(() => setIsOpen(false))}>
{editorErrors.length > 0 && (
<div className="mb-3 text-sm">
<p className="font-medium">
{t({
en: 'This instrument cannot be uploaded until the following type errors are resolved:',
fr: 'Cet instrument ne peut pas être téléversé tant que les erreurs de type suivantes ne sont pas résolues :'
})}
</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
{editorErrors.slice(0, MAX_DISPLAYED_ERRORS).map((error) => (
<li key={`${error.filename}:${error.line}:${error.message}`}>
{t({
en: `${error.filename} (line ${error.line}): ${error.message}`,
fr: `${error.filename} (ligne ${error.line}) : ${error.message}`
})}
</li>
))}
</ul>
{editorErrors.length > MAX_DISPLAYED_ERRORS && (
<p className="mt-2">
{t({
en: `and ${editorErrors.length - MAX_DISPLAYED_ERRORS} more`,
fr: `et ${editorErrors.length - MAX_DISPLAYED_ERRORS} de plus`
})}
</p>
)}
</div>
)}
<Button
disabled={!auth || editorErrors.length > 0}
type="button"
onClick={() => void handleSubmit().then(() => setIsOpen(false))}
>
{t({ en: 'Upload', fr: 'Téléverser' })}
</Button>
</Dialog.Body>
Expand Down
44 changes: 44 additions & 0 deletions apps/playground/src/hooks/useEditorErrorSync.ts
Original file line number Diff line number Diff line change
@@ -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]);
}
2 changes: 2 additions & 0 deletions apps/playground/src/store/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -36,6 +37,7 @@ export const useAppStore = create(
subscribeWithSelector(
immer<AppStore>((...a) => ({
...createAuthSlice(...a),
...createDiagnosticsSlice(...a),
...createEditorSlice(...a),
...createInstrumentSlice(...a),
...createSettingsSlice(...a),
Expand Down
10 changes: 10 additions & 0 deletions apps/playground/src/store/slices/diagnostics.slice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { DiagnosticsSlice, SliceCreator } from '../types';

export const createDiagnosticsSlice: SliceCreator<DiagnosticsSlice> = (set) => ({
editorErrors: [],
setEditorErrors: (editorErrors) => {
set((state) => {
state.editorErrors = editorErrors;
});
}
});
19 changes: 18 additions & 1 deletion apps/playground/src/store/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<T extends { [key: string]: unknown }> = StateCreator<
AppStore,
Expand Down
12 changes: 12 additions & 0 deletions docs/en/6-changelog/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/instrument-bundler/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/instrument-bundler",
"type": "module",
"version": "2.3.1",
"version": "2.4.0",
"sideEffects": [
"**/cli.ts"
],
Expand Down
2 changes: 1 addition & 1 deletion packages/instrument-guidelines/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 1 addition & 1 deletion packages/playground-url/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@opendatacapture/playground-url",
"type": "module",
"version": "2.3.1",
"version": "2.4.0",
"sideEffects": [
"**/cli.ts"
],
Expand Down
2 changes: 1 addition & 1 deletion packages/serve-instrument/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion runtime/v1/package.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
Loading