Skip to content
Merged
2 changes: 1 addition & 1 deletion formulus-formplayer/.storybook/preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const preview: Preview = {
Story => (
<ThemeProvider theme={theme}>
<CssBaseline />
<div style={{ padding: 24, maxWidth: 640 }}>
<div style={{ padding: 16, width: '100%', maxWidth: 640 }}>
<ClearFormulusBridgeCache>
<Story />
</ClearFormulusBridgeCache>
Expand Down
26 changes: 14 additions & 12 deletions formulus-formplayer/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,18 +38,18 @@ This file gives AI assistants and developers enough context to work effectively

## Source layout (high level)

| Area | Purpose |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/App.tsx` | Main app: JsonForms setup, renderer/cell registration, theme, init from `FormInitData`. |
| `src/index.tsx` | Entry: mounts React app; exposes `React` and `MaterialUI` on `window` for custom question type renderers. |
| `src/renderers/*` | JSON Forms **renderers** (e.g. signature, photo, sub-observation, file, GPS, swipe layout, finalize). Each has a **tester** (when to use) and a **component**. |
| `src/theme/` | MUI theme from `@ode/tokens` via `tokens-adapter.ts`; material wrappers for consistent look. |
| `src/services/` | `FormulusInterface.ts` (bridge client), `DraftService`, `ExtensionsLoader`, custom question type/validator loaders and registries. |
| `src/types/` | `FormulusInterfaceDefinition.ts` (synced from formulus), `CustomQuestionTypeContract.ts`, etc. |
| `src/components/` | Shared UI (e.g. `QuestionShell`, `FormLayout`, `DraftSelector`). |
| `src/builtinExtensions.ts` | Built-in extension functions (e.g. `getDynamicChoiceList`) used in forms. |
| `src/mocks/` | `webview-mock.ts` and `DevTestbed` for local dev without RN. |
| `scripts/` | `sync-interface.js`, `copy-to-rn.js`, `clean-rn-assets.js`. |
| Area | Purpose |
| -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `src/App.tsx` | Main app: JsonForms setup, renderer/cell registration, theme, init from `FormInitData`. |
| `src/index.tsx` | Entry: mounts React app; exposes `React` and `MaterialUI` on `window` for custom question type renderers. |
| `src/renderers/*` | JSON Forms **renderers** (e.g. signature, photo, likert, duration, sub-observation, file, GPS, swipe layout, finalize). Each has a **tester** (when to use) and a **component**. |
| `src/theme/` | MUI theme from `@ode/tokens` via `tokens-adapter.ts`; material wrappers for consistent look. |
| `src/services/` | `FormulusInterface.ts` (bridge client), `DraftService`, `ExtensionsLoader`, custom question type/validator loaders and registries. |
| `src/types/` | `FormulusInterfaceDefinition.ts` (synced from formulus), `CustomQuestionTypeContract.ts`, etc. |
| `src/components/` | Shared UI (e.g. `QuestionShell`, `FormLayout`, `DraftSelector`). |
| `src/builtinExtensions.ts` | Built-in extension functions (e.g. `getDynamicChoiceList`) used in forms. |
| `src/mocks/` | `webview-mock.ts` and `DevTestbed` for local dev without RN. |
| `scripts/` | `sync-interface.js`, `copy-to-rn.js`, `clean-rn-assets.js`. |

## Key technical constraints

Expand All @@ -76,6 +76,8 @@ Two **independent** layers:
- Docs: [form translations](https://opendataensemble.org/docs/guides/form-translations).

6. **Numeric inputs** (`type: integer` / `type: number`): Built-in control is **`NumberStepperRenderer`** (`src/renderers/NumberStepperRenderer.tsx`) backed by **`useNumericDraftInput`** (`src/hooks/useNumericDraftInput.ts`). Policy: **draft text while focused** (`type="text"` + `inputMode` + `enterKeyHint` from `FormContext`); observation JSON stores **JSON numbers only** (parse on commit, never strings); **never clamp** to `minimum`/`maximum` while typing—surface AJV errors instead. Custom numeric question types must follow the same contract (see `CustomQuestionTypeContract.ts`).
7. **Likert scale** (`format: "likert"`): Built-in **`LikertScaleQuestionRenderer`** + `src/components/likert/`. Config via schema `likert` object and `oneOf` options. All displays share one standard outlined-cell look; `colorMode` (`neutral` | `spectrum` | `stars`) only accents the **selected** option. Not an app-bundle custom type.
8. **Duration / timer** (`format: "duration"`): Built-in **`DurationQuestionRenderer`** + `src/components/duration/`. Stores **seconds** as JSON number; stopwatch requires explicit Save before value is committed.

## Adding or changing behavior

Expand Down
9 changes: 9 additions & 0 deletions formulus-formplayer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ After formplayer changes, verify on a phone WebView (or ODE Desktop form preview

Rebuild formplayer (`pnpm run build:copy`) before testing in Formulus or Desktop developer mode.

## Built-in question types: Likert scale and duration

`format: "likert"` and `format: "duration"` are **built-in** renderers (same pattern as `photo`, `gps`, `signature`) — not app-bundle `question_types/`.

- Likert: `src/renderers/LikertScaleQuestionRenderer.tsx` + `src/components/likert/`. Storybook: `Question Renderers/LikertScaleQuestionRenderer`.
- Duration: `src/renderers/DurationQuestionRenderer.tsx` + `src/components/duration/`. Storybook: `Question Renderers/DurationQuestionRenderer`.

Form-author configuration (schema `likert` / `duration` objects, display modes, colour, presets, layout, N/A) is documented on the docs site under **Reference → Form Specifications → Question Types** (`docs/reference/form-specifications.md`). Update that page when adding or changing options.

## Validation error display

Built-in controls and custom question types wrapped by `CustomQuestionTypeAdapter` show validation messages **once** in `QuestionShell` (error alert with icon below the field). Child widgets should use `error` / `validation.error` for red borders only — do not also render `validation.message` as `helperText` or inline copy.
Expand Down
13 changes: 12 additions & 1 deletion formulus-formplayer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ import SubObservationQuestionRenderer, {
} from './renderers/SubObservationQuestionRenderer';
import { shellMaterialRenderers } from './theme/material-wrappers';
import { numberStepperRenderer } from './renderers/NumberStepperRenderer';
import LikertScaleQuestionRenderer, {
likertScaleQuestionTester,
} from './renderers/LikertScaleQuestionRenderer';
import { injectLikertNotApplicable } from './components/likert/likertConfig';
import DurationQuestionRenderer, {
durationQuestionTester,
} from './renderers/DurationQuestionRenderer';
import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl';
import ShellInputControl, {
shellInputControlTester,
Expand Down Expand Up @@ -374,6 +381,8 @@ export const customRenderers = [
{ tester: gpsQuestionTester, renderer: GPSQuestionRenderer },
{ tester: videoQuestionTester, renderer: VideoQuestionRenderer },
{ tester: qrcodeQuestionTester, renderer: QrcodeQuestionRenderer },
{ tester: likertScaleQuestionTester, renderer: LikertScaleQuestionRenderer },
{ tester: durationQuestionTester, renderer: DurationQuestionRenderer },
{ tester: htmlLabelTester, renderer: HtmlLabelRenderer },
{ tester: adateQuestionTester, renderer: AdateQuestionRenderer },
{
Expand Down Expand Up @@ -664,7 +673,7 @@ function App() {
);
setUISchema(withLocale);
} else {
setSchema(formSchema as FormSchema);
setSchema(injectLikertNotApplicable(formSchema) as FormSchema);
const swipeLayoutUISchema = ensureSwipeLayoutRoot(
uiSchema as FormUISchema,
);
Expand Down Expand Up @@ -1033,6 +1042,8 @@ function App() {
return typeof data === 'string' && dateRegex.test(data);
});
instance.addFormat('sub-observation', () => true);
instance.addFormat('likert', () => true);
instance.addFormat('duration', () => true);

// Register custom question type formats with AJV
// Custom question types use "format": "formatName" in schemas (not "type")
Expand Down
286 changes: 286 additions & 0 deletions formulus-formplayer/src/components/duration/DurationControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Box,
Button,
LinearProgress,
TextField,
Typography,
Stack,
} from '@mui/material';
import { tokens } from '../../theme/tokens-adapter';
import {
formatDurationSeconds,
parseDurationConfig,
type DurationConfig,
} from './durationFormat';
import { useFormContext } from '../../App';
import { useNumericDraftInput } from '../../hooks/useNumericDraftInput';

export interface DurationControlProps {
value: unknown;
onChange: (value: unknown) => void;
schema: Record<string, unknown>;
enabled: boolean;
hasError: boolean;
}

type TimerPhase = 'idle' | 'running' | 'paused';

export default function DurationControl({
value,
onChange,
schema,
enabled,
hasError,
}: DurationControlProps) {
const config: DurationConfig = parseDurationConfig(schema);
const mode = config.mode ?? 'stopwatch';
const precision = config.precision ?? 1;
const allowManualEntry = config.allowManualEntry !== false;
const countdownFrom =
typeof config.countdownFrom === 'number' ? config.countdownFrom : null;

const { keyboardEnterKeyHint } = useFormContext();

const savedSeconds =
typeof value === 'number' && !Number.isNaN(value) ? value : undefined;

const [phase, setPhase] = useState<TimerPhase>('idle');
const [elapsedMs, setElapsedMs] = useState(0);
const startRef = useRef<number | null>(null);
const accumulatedRef = useRef(0);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);

const isCountdown = mode === 'countdown' && countdownFrom != null;
const displayMs = isCountdown
? Math.max(0, countdownFrom! * 1000 - elapsedMs)
: elapsedMs;
const displaySeconds = displayMs / 1000;

const stopInterval = useCallback(() => {
if (intervalRef.current != null) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
}, []);

const syncElapsed = useCallback(() => {
if (startRef.current == null) return;
setElapsedMs(
accumulatedRef.current + (performance.now() - startRef.current),
);
}, []);

useEffect(() => stopInterval, [stopInterval]);

const pauseTimer = () => {
if (startRef.current == null) return;
accumulatedRef.current += performance.now() - startRef.current;
startRef.current = null;
stopInterval();
setElapsedMs(accumulatedRef.current);
setPhase('paused');
};

useEffect(() => {
if (
isCountdown &&
phase === 'running' &&
countdownFrom != null &&
displaySeconds <= 0
) {
pauseTimer();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- pause when countdown completes
}, [isCountdown, phase, countdownFrom, displaySeconds]);

const startTimer = () => {
if (!enabled) return;
startRef.current = performance.now();
setPhase('running');
stopInterval();
intervalRef.current = setInterval(syncElapsed, 100);
};

const resetTimer = () => {
if (phase === 'running') {
const ok = window.confirm('Discard the current timing?');
if (!ok) return;
}
stopInterval();
startRef.current = null;
accumulatedRef.current = 0;
setElapsedMs(0);
setPhase('idle');
};

const saveTimer = () => {
const factor = 10 ** precision;
const seconds = Math.round((elapsedMs / 1000) * factor) / factor;
onChange(seconds);
if (phase === 'running') {
pauseTimer();
}
};

const manualPath = '__duration_manual__';
const manual = useNumericDraftInput({
data: value,
path: manualPath,
handleChange: (_p, v) => onChange(v),
schemaKind: 'number',
enterKeyHint: keyboardEnterKeyHint,
enabled,
});

if (mode === 'manual') {
return (
<Box>
<TextField
fullWidth
label="Duration (seconds)"
disabled={!enabled}
error={hasError}
value={manual.displayValue}
onFocus={manual.onFocus}
onBlur={manual.onBlur}
onChange={manual.onChange}
slotProps={{
htmlInput: manual.inputProps,
}}
/>
</Box>
);
}

const showTimer = mode === 'stopwatch' || isCountdown;
const progress =
isCountdown && countdownFrom! > 0
? Math.min(100, (displaySeconds / countdownFrom!) * 100)
: undefined;

const hasUnsavedTiming = elapsedMs > 0 && savedSeconds === undefined;
const showSavedLine = savedSeconds !== undefined;

return (
<Stack spacing={2} alignItems="stretch">
{showTimer && (
<>
<Typography
variant="h3"
component="div"
sx={{
fontFamily: 'monospace',
fontWeight: 600,
textAlign: 'center',
letterSpacing: 2,
py: 2,
borderRadius: 1,
bgcolor: 'action.hover',
border: hasError ? '1px solid' : undefined,
borderColor: hasError ? 'error.main' : undefined,
}}>
{formatDurationSeconds(displaySeconds, precision)}
</Typography>

{progress !== undefined && (
<LinearProgress
variant="determinate"
value={100 - progress}
sx={{ height: 8, borderRadius: 1 }}
/>
)}

{isCountdown && countdownFrom != null && (
<Typography
variant="caption"
color="text.secondary"
textAlign="center">
Target: {formatDurationSeconds(countdownFrom, precision)}
</Typography>
)}

<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
{phase === 'idle' && (
<Button
variant="contained"
disabled={!enabled}
onClick={startTimer}>
Start
</Button>
)}
{phase === 'running' && (
<Button
variant="contained"
disabled={!enabled}
onClick={pauseTimer}>
Pause
</Button>
)}
{phase === 'paused' && (
<Button
variant="contained"
disabled={!enabled}
onClick={startTimer}>
Resume
</Button>
)}
<Button
variant="outlined"
disabled={!enabled || (phase === 'idle' && elapsedMs === 0)}
onClick={resetTimer}>
Reset
</Button>
{(phase === 'paused' || (phase === 'idle' && elapsedMs > 0)) && (
<Button
variant="contained"
color="success"
disabled={!enabled || elapsedMs === 0}
onClick={saveTimer}>
Save
</Button>
)}
</Stack>

{showSavedLine && (
<Typography
variant="body2"
sx={{ color: tokens.color.semantic.success['600'] }}>
Saved: {formatDurationSeconds(savedSeconds, precision)}
</Typography>
)}

{phase !== 'idle' && hasUnsavedTiming && (
<Typography variant="caption" color="text.secondary">
Not saved yet — pause and tap Save to record this duration.
</Typography>
)}
</>
)}

{allowManualEntry && (
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ mb: 0.5, display: 'block' }}>
Or enter duration manually (seconds)
</Typography>
<TextField
fullWidth
size="small"
disabled={!enabled}
error={hasError}
value={manual.displayValue}
onFocus={manual.onFocus}
onBlur={manual.onBlur}
onChange={manual.onChange}
slotProps={{
htmlInput: manual.inputProps,
}}
/>
</Box>
)}
</Stack>
);
}
Loading
Loading