diff --git a/formulus-formplayer/.storybook/preview.tsx b/formulus-formplayer/.storybook/preview.tsx index e6f6be78b..702393dfa 100644 --- a/formulus-formplayer/.storybook/preview.tsx +++ b/formulus-formplayer/.storybook/preview.tsx @@ -24,7 +24,7 @@ const preview: Preview = { Story => ( -
+
diff --git a/formulus-formplayer/AGENTS.md b/formulus-formplayer/AGENTS.md index 002cdaf1a..f0b3896e8 100644 --- a/formulus-formplayer/AGENTS.md +++ b/formulus-formplayer/AGENTS.md @@ -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 @@ -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 diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index 04293ba41..11b3b38e2 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -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. diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index dfa85221e..14eaa1b66 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -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, @@ -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 }, { @@ -664,7 +673,7 @@ function App() { ); setUISchema(withLocale); } else { - setSchema(formSchema as FormSchema); + setSchema(injectLikertNotApplicable(formSchema) as FormSchema); const swipeLayoutUISchema = ensureSwipeLayoutRoot( uiSchema as FormUISchema, ); @@ -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") diff --git a/formulus-formplayer/src/components/duration/DurationControl.tsx b/formulus-formplayer/src/components/duration/DurationControl.tsx new file mode 100644 index 000000000..edbf69aab --- /dev/null +++ b/formulus-formplayer/src/components/duration/DurationControl.tsx @@ -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; + 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('idle'); + const [elapsedMs, setElapsedMs] = useState(0); + const startRef = useRef(null); + const accumulatedRef = useRef(0); + const intervalRef = useRef | 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 ( + + + + ); + } + + 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 ( + + {showTimer && ( + <> + + {formatDurationSeconds(displaySeconds, precision)} + + + {progress !== undefined && ( + + )} + + {isCountdown && countdownFrom != null && ( + + Target: {formatDurationSeconds(countdownFrom, precision)} + + )} + + + {phase === 'idle' && ( + + )} + {phase === 'running' && ( + + )} + {phase === 'paused' && ( + + )} + + {(phase === 'paused' || (phase === 'idle' && elapsedMs > 0)) && ( + + )} + + + {showSavedLine && ( + + Saved: {formatDurationSeconds(savedSeconds, precision)} + + )} + + {phase !== 'idle' && hasUnsavedTiming && ( + + Not saved yet — pause and tap Save to record this duration. + + )} + + )} + + {allowManualEntry && ( + + + Or enter duration manually (seconds) + + + + )} + + ); +} diff --git a/formulus-formplayer/src/components/duration/durationFormat.ts b/formulus-formplayer/src/components/duration/durationFormat.ts new file mode 100644 index 000000000..b9b199185 --- /dev/null +++ b/formulus-formplayer/src/components/duration/durationFormat.ts @@ -0,0 +1,65 @@ +/** Format seconds for display (MM:SS.s or HH:MM:SS.s). */ +export function formatDurationSeconds( + totalSeconds: number, + precision = 1, +): string { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) { + return '00:00.0'; + } + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + const secFixed = seconds.toFixed(precision); + const secParts = secFixed.split('.'); + const secWhole = secParts[0].padStart(2, '0'); + const secFrac = + precision > 0 ? `.${secParts[1] ?? '0'.repeat(precision)}` : ''; + + if (hours > 0) { + return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${secWhole}${secFrac}`; + } + return `${String(minutes).padStart(2, '0')}:${secWhole}${secFrac}`; +} + +/** Human-readable duration for finalize / review. */ +export function formatDurationHuman(totalSeconds: number): string { + if ( + totalSeconds === undefined || + totalSeconds === null || + Number.isNaN(totalSeconds) + ) { + return 'Not provided'; + } + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + if (mins === 0) { + return `${secs.toFixed(1)} sec`; + } + if (secs < 0.05) { + return `${mins} min`; + } + return `${mins} min ${secs.toFixed(1)} sec`; +} + +export type DurationMode = 'stopwatch' | 'countdown' | 'manual'; + +export interface DurationConfig { + mode?: DurationMode; + unit?: 'seconds'; + precision?: number; + allowManualEntry?: boolean; + countdownFrom?: number | null; +} + +/** JSON Schema field with optional duration extension (formplayer built-in). */ +export type DurationJsonSchema = import('@jsonforms/core').JsonSchema7 & { + duration?: DurationConfig; +}; + +export function parseDurationConfig( + schema: Record, +): DurationConfig { + const raw = schema.duration; + if (!raw || typeof raw !== 'object') return {}; + return raw as DurationConfig; +} diff --git a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx new file mode 100644 index 000000000..d9cfb07ed --- /dev/null +++ b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx @@ -0,0 +1,535 @@ +import React, { useCallback } from 'react'; +import BlockOutlinedIcon from '@mui/icons-material/BlockOutlined'; +import { + alpha, + Box, + ButtonBase, + FormControlLabel, + Radio, + RadioGroup, + Rating, + Slider, + Typography, + useMediaQuery, + useTheme, +} from '@mui/material'; +import { getSpectrumColor } from './likertColors'; +import type { LikertOption, ResolvedLikertOptions } from './likertTypes'; +import { isNotApplicableValue, valuesEqual } from './likertConfig'; +import { choiceListSx } from '../../theme/choiceLayout'; + +export interface LikertScaleControlProps { + value: unknown; + onChange: (value: unknown) => void; + resolved: ResolvedLikertOptions; + enabled: boolean; + hasError: boolean; +} + +function numericValues(options: LikertOption[]): number[] { + return options.map(o => + typeof o.value === 'number' ? o.value : Number(o.value), + ); +} + +/** Left/right captions under a scale — standard endpoint anchor pattern. */ +function EndpointLabels({ options }: { options: LikertOption[] }) { + if (options.length < 2) return null; + return ( + + + {options[0].label} + + + {options[options.length - 1].label} + + + ); +} + +/** + * True when the endpoints carry real word labels (not just the numeric + * value). Research on survey scales recommends pairing numeric anchors with + * verbal endpoint labels, so we surface them under numeric scales. + */ +function hasWordEndpoints(options: LikertOption[]): boolean { + if (options.length < 2) return false; + const first = options[0]; + const last = options[options.length - 1]; + return ( + first.label !== String(first.value) || last.label !== String(last.value) + ); +} + +export default function LikertScaleControl({ + value, + onChange, + resolved, + enabled, + hasError, +}: LikertScaleControlProps) { + const theme = useTheme(); + const isPhone = useMediaQuery(theme.breakpoints.down('sm')); + const { + options, + display, + colorMode, + endpointLabelsOnly, + allowClear, + allowNotApplicable, + notApplicableLabel, + notApplicableValue, + layout, + } = resolved; + + const isNa = isNotApplicableValue(value, notApplicableValue); + const scaleValue = isNa ? null : value; + const vertical = layout.mode === 'vertical'; + const useGrid = layout.mode === 'columns'; + + const handleSelect = useCallback( + (newValue: unknown) => { + if (!enabled) return; + if ( + allowClear && + scaleValue !== null && + scaleValue !== undefined && + valuesEqual(scaleValue, newValue) + ) { + onChange(undefined); + return; + } + onChange(newValue); + }, + [allowClear, enabled, onChange, scaleValue], + ); + + const handleNa = useCallback(() => { + if (!enabled) return; + onChange(isNa ? undefined : notApplicableValue); + }, [enabled, isNa, notApplicableValue, onChange]); + + /** Accent for the selected option: theme primary, or semantic spectrum. */ + const accentFor = (index: number): string => + colorMode === 'spectrum' + ? getSpectrumColor(index, options.length) + : theme.palette.primary.main; + + const neutralBorder = hasError + ? theme.palette.error.main + : theme.palette.divider; + + type CellWidth = 'word' | 'emoji' | 'compact'; + + /** + * One shared cell style for buttons / numeric / emoji: outlined pills with a + * faint fill so options read as tappable (not bare labels). Selected option + * gets an accent tint; in review/disabled mode the answer stays prominent + * while unselected options fade back. + */ + const cellSx = (index: number, selected: boolean, width: CellWidth) => { + const accent = accentFor(index); + const unselectedBg = + width === 'compact' || width === 'emoji' + ? theme.palette.action.hover + : alpha(theme.palette.text.primary, 0.04); + return { + minHeight: 44, + px: 1, + py: 0.75, + borderRadius: 1.5, + border: '1px solid', + borderColor: selected ? accent : neutralBorder, + backgroundColor: selected ? alpha(accent, 0.12) : unselectedBg, + color: selected ? accent : theme.palette.text.primary, + fontWeight: selected ? 600 : 400, + fontSize: '0.8125rem', + lineHeight: 1.3, + textAlign: 'center' as const, + overflowWrap: 'break-word' as const, + hyphens: 'auto' as const, + transition: theme.transitions.create( + ['border-color', 'background-color', 'color', 'opacity'], + { duration: theme.transitions.duration.shortest }, + ), + '&:hover': enabled + ? { + backgroundColor: selected + ? alpha(accent, 0.16) + : theme.palette.action.selected, + } + : undefined, + '&.Mui-focusVisible': { + outline: `2px solid ${alpha(accent, 0.5)}`, + outlineOffset: 1, + }, + '&.Mui-disabled': selected + ? { + color: accent, + borderColor: accent, + backgroundColor: alpha(accent, 0.14), + opacity: 1, + } + : { + color: theme.palette.text.disabled, + borderColor: theme.palette.divider, + backgroundColor: alpha(theme.palette.text.primary, 0.02), + opacity: 0.55, + }, + }; + }; + + const naCellSx = () => { + const accent = theme.palette.text.secondary; + return { + minHeight: 44, + px: 1.25, + py: 0.75, + borderRadius: 1.5, + border: '1px dashed', + borderColor: isNa ? accent : neutralBorder, + backgroundColor: isNa ? alpha(accent, 0.12) : theme.palette.action.hover, + color: isNa ? accent : theme.palette.text.secondary, + fontWeight: isNa ? 600 : 400, + fontSize: '0.8125rem', + gap: 0.5, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + textTransform: 'none' as const, + '&.Mui-focusVisible': { + outline: `2px solid ${alpha(accent, 0.4)}`, + outlineOffset: 1, + }, + }; + }; + + /** + * Row container per variant. Word scales use an equal-column CSS grid so every + * option is the same width and the last one never orphans to a full-width row + * (a flex-wrap artifact). Compact/emoji cells wrap in a flex row. + */ + const cellRowSxFor = (width: CellWidth) => { + if (useGrid) return choiceListSx(layout); + if (vertical) { + return { display: 'flex', flexDirection: 'column', gap: 0.75 }; + } + if (width === 'word' || width === 'emoji') { + const cols = `repeat(${options.length}, minmax(0, 1fr))`; + return { + display: 'grid', + // Word scales stack on phones; emoji stay in one equal-width row (they + // are compact), both use equal columns from the sm breakpoint up. + gridTemplateColumns: width === 'word' ? { xs: '1fr', sm: cols } : cols, + gap: 0.75, + alignItems: 'stretch', + }; + } + return { + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: 0.75, + }; + }; + + /** + * Horizontal per-item sizing: + * - `word` / `emoji` : sizing comes from the equal-column grid; only guard + * against overflow so labels wrap inside the cell (equal widths). + * - `compact`: small fixed cells (numeric/NPS), touch-friendly (44px min). + */ + const horizontalCellSx = (width: CellWidth) => { + switch (width) { + case 'word': + case 'emoji': + return useGrid ? { width: '100%' } : { minWidth: 0 }; + case 'compact': + default: + return { flex: '0 0 auto', minWidth: 48 }; + } + }; + + const renderNaCell = (inline: boolean) => { + if (!allowNotApplicable) return null; + return ( + + + {notApplicableLabel} + + ); + }; + + // Inline N/A only in compact flex rows (numeric / endpoint-only buttons). + // Grid-based scales (word buttons, emoji) keep the N/A below so the + // equal-column grid stays uniform (no mismatched extra cell). + const inlineNa = + allowNotApplicable && + !vertical && + (display === 'numeric' || (display === 'buttons' && endpointLabelsOnly)); + + const renderCells = ( + content: (opt: LikertOption, index: number) => React.ReactNode, + width: CellWidth, + showEndpoints: boolean, + ) => ( + + + {options.map((opt, index) => { + const selected = valuesEqual(scaleValue, opt.value); + return ( + handleSelect(opt.value)} + aria-label={opt.label} + aria-pressed={selected} + focusRipple + sx={{ + ...cellSx(index, selected, width), + ...(vertical + ? { justifyContent: 'flex-start', textAlign: 'left', px: 1.5 } + : horizontalCellSx(width)), + }}> + {content(opt, index)} + + ); + })} + {inlineNa && renderNaCell(true)} + + {showEndpoints && !vertical && } + + ); + + const renderButtons = () => + renderCells( + opt => (endpointLabelsOnly ? String(opt.value) : opt.label), + endpointLabelsOnly ? 'compact' : 'word', + endpointLabelsOnly, + ); + + const renderNumeric = () => + renderCells( + opt => String(opt.value), + 'compact', + // Surface verbal endpoint anchors when the form provides them. + hasWordEndpoints(options), + ); + + const renderEmoji = () => + renderCells( + opt => ( + + + {opt.emoji ?? opt.label.charAt(0)} + + {/* Always pair the emoji with its text label to avoid the + cultural/interpretive ambiguity of emoji-only scales. */} + + {opt.label} + + + ), + 'emoji', + false, + ); + + const renderRadio = () => { + // Phones: stack rows with the label beside each radio — the standard + // readable mobile pattern. Tablets/desktop: a row with labels below. + const stack = vertical || (isPhone && !endpointLabelsOnly); + return ( + + { + const opt = options.find(o => String(o.value) === e.target.value); + if (opt) handleSelect(opt.value); + }}> + + {options.map(opt => ( + } + labelPlacement={stack ? 'end' : 'bottom'} + label={ + + {endpointLabelsOnly ? String(opt.value) : opt.label} + + } + sx={ + stack + ? undefined + : endpointLabelsOnly + ? { + flex: '0 1 48px', + m: 0, + minWidth: 40, + alignItems: 'center', + } + : { + flex: '1 1 64px', + m: 0, + minWidth: 56, + alignItems: 'center', + } + } + /> + ))} + + + {endpointLabelsOnly && !vertical && ( + + )} + + ); + }; + + const renderSlider = () => { + const nums = numericValues(options); + const min = Math.min(...nums); + const max = Math.max(...nums); + const step = nums.length > 1 ? Math.abs(nums[1] - nums[0]) || 1 : 1; + const current = + typeof scaleValue === 'number' + ? scaleValue + : scaleValue !== null && scaleValue !== undefined + ? Number(scaleValue) + : min; + const currentIndex = options.findIndex(o => valuesEqual(o.value, current)); + + return ( + + handleSelect(v as number)} + valueLabelDisplay="on" + valueLabelFormat={v => `${v}/${max}`} + sx={ + colorMode === 'spectrum' && currentIndex >= 0 + ? { color: accentFor(currentIndex) } + : undefined + } + /> + + + ); + }; + + const renderStars = () => { + const starIndex = options.findIndex(o => valuesEqual(o.value, scaleValue)); + const ratingValue = starIndex >= 0 ? starIndex + 1 : null; + + return ( + + { + if (newRating == null) { + if (allowClear) onChange(undefined); + return; + } + handleSelect(options[newRating - 1].value); + }} + getLabelText={star => options[star - 1]?.label ?? String(star)} + /> + {ratingValue !== null && ( + + {options[ratingValue - 1]?.label ?? ''} + + )} + + ); + }; + + const renderScale = () => { + switch (display) { + case 'radio': + return renderRadio(); + case 'slider': + return renderSlider(); + case 'numeric': + return renderNumeric(); + case 'stars': + return renderStars(); + case 'emoji': + return renderEmoji(); + case 'buttons': + default: + return renderButtons(); + } + }; + + if (options.length === 0) { + return ( + + No scale options configured. + + ); + } + + return ( + + {renderScale()} + {allowNotApplicable && !inlineNa && renderNaCell(false)} + + ); +} diff --git a/formulus-formplayer/src/components/likert/likertColors.ts b/formulus-formplayer/src/components/likert/likertColors.ts new file mode 100644 index 000000000..033c5aaa6 --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertColors.ts @@ -0,0 +1,31 @@ +import { tokens } from '../../theme/tokens-adapter'; +import type { LikertColorMode } from './likertTypes'; + +function tokenOr(fallback: string, value?: string): string { + return value && value.length > 0 ? value : fallback; +} + +const ERROR = tokenOr('#F44336', tokens.color.semantic.error['500']); +const WARNING = tokenOr('#FF9500', tokens.color.semantic.warning['500']); +const SUCCESS = tokenOr('#34C759', tokens.color.semantic.success['500']); + +/** + * Semantic accent for an option position: low → error, mid → warning, + * high → success. Applied to the SELECTED option only — unselected options + * always stay neutral so the scale looks standard and uncluttered. + */ +export function getSpectrumColor(index: number, total: number): string { + if (total <= 1) return WARNING; + const t = index / (total - 1); + if (t < 0.4) return ERROR; + if (t <= 0.6) return WARNING; + return SUCCESS; +} + +export function resolveEffectiveColorMode( + display: string, + colorMode?: LikertColorMode, +): LikertColorMode { + if (display === 'stars') return 'stars'; + return colorMode ?? 'neutral'; +} diff --git a/formulus-formplayer/src/components/likert/likertConfig.ts b/formulus-formplayer/src/components/likert/likertConfig.ts new file mode 100644 index 000000000..86f4bfd58 --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertConfig.ts @@ -0,0 +1,201 @@ +import type { JsonSchema } from '@jsonforms/core'; +import { parseChoiceLayout } from '../../theme/choiceLayout'; +import { getPresetOptions } from './likertPresets'; +import type { + LikertConfig, + LikertDisplay, + LikertOneOfEntry, + LikertOption, + ResolvedLikertOptions, +} from './likertTypes'; +import { resolveEffectiveColorMode } from './likertColors'; + +type OneOfEntry = LikertOneOfEntry; + +function oneOfToOptions(oneOf: OneOfEntry[]): LikertOption[] { + return oneOf.map(entry => ({ + value: entry.const, + label: entry.title ?? String(entry.const), + emoji: entry.emoji, + })); +} + +export function parseLikertConfig(schema: JsonSchema): LikertConfig { + const raw = (schema as Record).likert; + if (!raw || typeof raw !== 'object') return {}; + return raw as LikertConfig; +} + +export function resolveLikertOptions( + schema: JsonSchema, + uischema?: { options?: Record }, +): ResolvedLikertOptions { + const config = parseLikertConfig(schema); + const uiOptions = uischema?.options ?? {}; + + let options: LikertOption[] = []; + const schemaOneOf = (schema as { oneOf?: OneOfEntry[] }).oneOf; + if (schemaOneOf && schemaOneOf.length > 0) { + options = oneOfToOptions(schemaOneOf); + } else if (config.preset) { + options = getPresetOptions(config.preset); + } + + // Localized option labels: `ui.json` `options.oneOf` (populated per active + // locale by the translation merge) overrides titles/emoji by matching + // `const`, so scale labels can be translated the same way as custom types. + const uiOneOf = Array.isArray(uiOptions.oneOf) + ? (uiOptions.oneOf as OneOfEntry[]) + : undefined; + if (uiOneOf && uiOneOf.length > 0) { + if (options.length === 0) { + options = oneOfToOptions(uiOneOf); + } else { + const byConst = new Map( + uiOneOf.map(entry => [String(entry.const), entry]), + ); + options = options.map(opt => { + const override = byConst.get(String(opt.value)); + if (!override) return opt; + return { + ...opt, + label: override.title ?? opt.label, + emoji: override.emoji ?? opt.emoji, + }; + }); + } + } + + const allowNotApplicable = config.allowNotApplicable === true; + const notApplicableValue = + config.notApplicableValue !== undefined ? config.notApplicableValue : null; + + // The N/A choice is rendered by the control's own pill, so drop any scale + // option that matches the N/A value (it may be present because we inject a + // matching branch into `oneOf` for validation — see injectLikertNotApplicable). + if (allowNotApplicable) { + options = options.filter(o => !valuesEqual(o.value, notApplicableValue)); + } + + const uiDisplay = uiOptions.display as LikertDisplay | undefined; + const display = + uiDisplay ?? config.display ?? (options.length > 7 ? 'slider' : 'buttons'); + + const layout = + uiOptions.orientation != null + ? parseChoiceLayout(uiOptions) + : { mode: 'horizontal' as const }; + + const colorMode = resolveEffectiveColorMode(display, config.colorMode); + + return { + options, + display, + colorMode, + endpointLabelsOnly: + config.endpointLabelsOnly ?? uiOptions.endpointLabelsOnly === true, + allowClear: config.allowClear !== false, + allowNotApplicable, + notApplicableLabel: config.notApplicableLabel ?? 'Not applicable', + notApplicableValue, + layout, + }; +} + +export function findOptionLabel( + options: LikertOption[], + value: unknown, +): string | null { + if (value === undefined || value === null || value === '') return null; + const match = options.find(o => valuesEqual(o.value, value)); + return match?.label ?? String(value); +} + +export function valuesEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (typeof a === 'number' && typeof b === 'number') return a === b; + return String(a) === String(b); +} + +export function isNotApplicableValue( + value: unknown, + naValue: null | string | number, +): boolean { + if (value === undefined || value === '') return false; + if (naValue === null) return value === null; + return valuesEqual(value, naValue); +} + +type MutableSchemaNode = Record & { + type?: unknown; + format?: unknown; + likert?: { + allowNotApplicable?: boolean; + notApplicableValue?: null | string | number; + notApplicableLabel?: string; + }; + oneOf?: Array<{ const?: unknown; title?: string }>; + properties?: Record; + items?: unknown; +}; + +function ensureTypeAllowsNull(node: MutableSchemaNode): void { + if (node.type === undefined) return; + if (Array.isArray(node.type)) { + if (!node.type.includes('null')) node.type = [...node.type, 'null']; + } else if (typeof node.type === 'string' && node.type !== 'null') { + node.type = [node.type, 'null']; + } +} + +function normalizeLikertNode(node: MutableSchemaNode): void { + if (node.format === 'likert' && node.likert?.allowNotApplicable === true) { + const naValue = + node.likert.notApplicableValue !== undefined + ? node.likert.notApplicableValue + : null; + + if (naValue === null) ensureTypeAllowsNull(node); + + if (Array.isArray(node.oneOf)) { + const exists = node.oneOf.some(entry => + valuesEqual(entry?.const, naValue), + ); + if (!exists) { + node.oneOf = [ + ...node.oneOf, + { + const: naValue as string | number | null, + title: node.likert.notApplicableLabel ?? 'Not applicable', + }, + ]; + } + } + } + + if (node.properties && typeof node.properties === 'object') { + for (const child of Object.values(node.properties)) { + if (child && typeof child === 'object') { + normalizeLikertNode(child as MutableSchemaNode); + } + } + } + + if (node.items && typeof node.items === 'object') { + normalizeLikertNode(node.items as MutableSchemaNode); + } +} + +/** + * Returns a deep clone of a form schema in which every `format: "likert"` field + * with `allowNotApplicable` accepts its N/A value during validation. Without + * this, a `null` (N/A) value fails the field's `oneOf`/`type` constraints even + * though the control offers the N/A choice. The injected branch is filtered out + * of the displayed options by resolveLikertOptions, so no duplicate button shows. + */ +export function injectLikertNotApplicable(schema: T): T { + if (!schema || typeof schema !== 'object') return schema; + const clone = JSON.parse(JSON.stringify(schema)) as MutableSchemaNode; + normalizeLikertNode(clone); + return clone as unknown as T; +} diff --git a/formulus-formplayer/src/components/likert/likertPresets.ts b/formulus-formplayer/src/components/likert/likertPresets.ts new file mode 100644 index 000000000..8c45b84c6 --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertPresets.ts @@ -0,0 +1,55 @@ +import type { LikertOption, LikertPreset } from './likertTypes'; + +export const LIKERT_PRESET_OPTIONS: Record = { + agreement: [ + { value: 1, label: 'Strongly disagree' }, + { value: 2, label: 'Disagree' }, + { value: 3, label: 'Neutral' }, + { value: 4, label: 'Agree' }, + { value: 5, label: 'Strongly agree' }, + ], + frequency: [ + { value: 1, label: 'Never' }, + { value: 2, label: 'Rarely' }, + { value: 3, label: 'Sometimes' }, + { value: 4, label: 'Often' }, + { value: 5, label: 'Always' }, + ], + satisfaction: [ + { value: 1, label: 'Very dissatisfied' }, + { value: 2, label: 'Dissatisfied' }, + { value: 3, label: 'Neutral' }, + { value: 4, label: 'Satisfied' }, + { value: 5, label: 'Very satisfied' }, + ], + importance: [ + { value: 1, label: 'Not important' }, + { value: 2, label: 'Slightly important' }, + { value: 3, label: 'Moderately important' }, + { value: 4, label: 'Important' }, + { value: 5, label: 'Very important' }, + ], + likelihood: [ + { value: 1, label: 'Very unlikely' }, + { value: 2, label: 'Unlikely' }, + { value: 3, label: 'Neutral' }, + { value: 4, label: 'Likely' }, + { value: 5, label: 'Very likely' }, + ], + numeric_0_10: Array.from({ length: 11 }, (_, i) => ({ + value: i, + label: String(i), + })), + numeric_1_5: Array.from({ length: 5 }, (_, i) => ({ + value: i + 1, + label: String(i + 1), + })), + numeric_1_7: Array.from({ length: 7 }, (_, i) => ({ + value: i + 1, + label: String(i + 1), + })), +}; + +export function getPresetOptions(preset: LikertPreset): LikertOption[] { + return LIKERT_PRESET_OPTIONS[preset].map(o => ({ ...o })); +} diff --git a/formulus-formplayer/src/components/likert/likertSchemaHelpers.ts b/formulus-formplayer/src/components/likert/likertSchemaHelpers.ts new file mode 100644 index 000000000..84e92d7cf --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertSchemaHelpers.ts @@ -0,0 +1,50 @@ +import type { + LikertConfig, + LikertJsonSchema, + LikertObjectJsonSchema, + LikertOneOfEntry, +} from './likertTypes'; + +export function likertField( + oneOf: LikertOneOfEntry[], + options: { + title?: string; + type?: 'integer' | ['integer', 'null']; + likert?: LikertConfig; + } = {}, +): LikertJsonSchema { + return { + type: options.type ?? 'integer', + format: 'likert', + title: options.title ?? 'Question', + oneOf, + ...(options.likert ? { likert: options.likert } : {}), + }; +} + +export function likertPresetField( + preset: NonNullable, + options: { + title?: string; + likert?: Omit; + } = {}, +): LikertJsonSchema { + return { + type: 'integer', + format: 'likert', + title: options.title ?? 'Question', + likert: { preset, ...options.likert }, + }; +} + +export function likertObjectSchema( + field: LikertJsonSchema, + fieldName = 'satisfaction', + required?: string[], +): LikertObjectJsonSchema { + return { + type: 'object', + properties: { [fieldName]: field }, + ...(required?.length ? { required } : {}), + }; +} diff --git a/formulus-formplayer/src/components/likert/likertTypes.ts b/formulus-formplayer/src/components/likert/likertTypes.ts new file mode 100644 index 000000000..4009f8d9a --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertTypes.ts @@ -0,0 +1,72 @@ +import type { JsonSchema7 } from '@jsonforms/core'; +import type { ChoiceLayout } from '../../theme/choiceLayout'; + +export type LikertPreset = + | 'agreement' + | 'frequency' + | 'satisfaction' + | 'importance' + | 'likelihood' + | 'numeric_0_10' + | 'numeric_1_5' + | 'numeric_1_7'; + +export type LikertDisplay = + | 'buttons' + | 'radio' + | 'slider' + | 'numeric' + | 'stars' + | 'emoji'; + +export type LikertColorMode = 'neutral' | 'spectrum' | 'stars'; + +/** Single scale option in schema `oneOf` (emoji is display-only metadata). */ +export interface LikertOneOfEntry { + const: string | number; + title?: string; + emoji?: string; +} + +export interface LikertOption { + value: string | number; + label: string; + emoji?: string; +} + +export interface LikertConfig { + preset?: LikertPreset; + display?: LikertDisplay; + colorMode?: LikertColorMode; + endpointLabelsOnly?: boolean; + allowClear?: boolean; + allowNotApplicable?: boolean; + notApplicableLabel?: string; + notApplicableValue?: null | string | number; +} + +export interface ResolvedLikertOptions { + options: LikertOption[]; + display: LikertDisplay; + colorMode: LikertColorMode; + endpointLabelsOnly: boolean; + allowClear: boolean; + allowNotApplicable: boolean; + notApplicableLabel: string; + notApplicableValue: null | string | number; + layout: ChoiceLayout; +} + +/** JSON Schema field with Likert extension (formplayer built-in). */ +export type LikertJsonSchema = JsonSchema7 & { + format: 'likert'; + likert?: LikertConfig; + oneOf?: LikertOneOfEntry[]; +}; + +/** Object wrapper schema for Storybook / tests with a Likert property. */ +export type LikertObjectJsonSchema = JsonSchema7 & { + type: 'object'; + properties: Record; + required?: string[]; +}; diff --git a/formulus-formplayer/src/renderers/DurationQuestionRenderer.test.tsx b/formulus-formplayer/src/renderers/DurationQuestionRenderer.test.tsx new file mode 100644 index 000000000..2488f1c02 --- /dev/null +++ b/formulus-formplayer/src/renderers/DurationQuestionRenderer.test.tsx @@ -0,0 +1,153 @@ +// @vitest-environment jsdom +import React, { useState } from 'react'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import DurationQuestionRenderer, { + durationQuestionTester, +} from './DurationQuestionRenderer'; +import DurationControl from '../components/duration/DurationControl'; +import { + formatDurationHuman, + formatDurationSeconds, +} from '../components/duration/durationFormat'; +import type { DurationJsonSchema } from '../components/duration/durationFormat'; +import { shellMaterialRenderers } from '../theme/material-wrappers'; + +const ajv = new Ajv({ allErrors: true, strict: false }); +ajv.addFormat('duration', () => true); + +const durationFieldSchema: DurationJsonSchema = { + type: 'number', + format: 'duration', + title: 'Time to complete the task', + minimum: 0, + duration: { + mode: 'stopwatch', + unit: 'seconds', + precision: 1, + allowManualEntry: true, + }, +}; + +const durationSchema: JsonSchema7 = { + type: 'object', + properties: { + task_duration: durationFieldSchema, + }, +}; + +const durationUischema: UISchemaElement = { + type: 'Control', + scope: '#/properties/task_duration', +}; + +const productionRenderers = [ + ...shellMaterialRenderers, + ...materialRenderers, + { tester: durationQuestionTester, renderer: DurationQuestionRenderer }, +]; + +function DurationIntegrationHarness({ + initialData = {}, +}: { + initialData?: Record; +}) { + const [data, setData] = useState>(initialData); + return ( + + + setData(d || {})} + /> + + + ); +} + +afterEach(() => cleanup()); + +describe('durationFormat', () => { + it('formats seconds as MM:SS.s', () => { + expect(formatDurationSeconds(83.4, 1)).toBe('01:23.4'); + }); + + it('formats human-readable duration', () => { + expect(formatDurationHuman(83.4)).toBe('1 min 23.4 sec'); + }); +}); + +describe('durationQuestionTester', () => { + it('matches schema with format duration', () => { + const rank = durationQuestionTester(durationUischema, durationSchema, { + rootSchema: durationSchema, + config: {}, + } as never); + expect(rank).toBe(12); + }); +}); + +describe('DurationControl', () => { + it('renders stopwatch controls', () => { + render( + + + {}} + schema={durationFieldSchema as Record} + enabled + hasError={false} + /> + + , + ); + expect(screen.getByText('Start')).toBeTruthy(); + expect(screen.getByText('00:00.0')).toBeTruthy(); + }); +}); + +describe('DurationQuestionRenderer integration', () => { + it('renders via JsonForms with production renderer order', () => { + render(); + expect(screen.getByText('Start')).toBeTruthy(); + expect(screen.getByText('Time to complete the task')).toBeTruthy(); + }); + + it('shows saved value when provided', () => { + render( + , + ); + expect(screen.getByText(/Saved:/)).toBeTruthy(); + }); + + it('allows manual entry of seconds', () => { + render(); + const input = screen.getByRole('textbox'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: '12.5' } }); + fireEvent.blur(input); + expect(input).toBeTruthy(); + }); +}); diff --git a/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx b/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx new file mode 100644 index 000000000..21b5fdf9d --- /dev/null +++ b/formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { ControlProps, rankWith, schemaMatches } from '@jsonforms/core'; +import QuestionShell from '../components/QuestionShell'; +import DurationControl from '../components/duration/DurationControl'; +import { formatControlErrors } from '../utils/formatControlErrors'; + +export const durationQuestionTester = rankWith( + 12, + schemaMatches( + schema => + typeof schema === 'object' && + schema !== null && + (schema as { format?: string }).format === 'duration', + ), +); + +const DurationQuestionRenderer: React.FC = ({ + data, + handleChange, + path, + errors, + schema, + uischema, + enabled = true, + visible = true, + label, + required, +}) => { + const hasErrors = Boolean( + errors && (Array.isArray(errors) ? errors.length > 0 : true), + ); + const errorMessage = formatControlErrors( + hasErrors + ? Array.isArray(errors) + ? errors.map((e: { message?: string } | string) => + typeof e === 'object' && e && 'message' in e && e.message + ? String(e.message) + : String(e), + ) + : errors + : null, + ); + + if (visible === false) { + return null; + } + + const title = + (uischema as { label?: string })?.label ?? label ?? schema.title ?? ''; + const description = schema.description; + + return ( + + handleChange(path, newValue)} + schema={schema as Record} + enabled={enabled} + hasError={Boolean(errorMessage)} + /> + + ); +}; + +export default withJsonFormsControlProps(DurationQuestionRenderer); diff --git a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx index 00d334d33..ba02193a6 100644 --- a/formulus-formplayer/src/renderers/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/renderers/FinalizeRenderer.tsx @@ -9,6 +9,7 @@ import { ErrorObject } from 'ajv'; import { useFormContext } from '../App'; import EditIcon from '@mui/icons-material/Edit'; import { displayAdate } from '../utils/adateUtils'; +import { formatDurationHuman } from '../components/duration/durationFormat'; import { useOdeT } from '../i18n/useOdeT'; import { translateAjvError } from '../i18n/createOdeI18n'; import { FormplayerLocaleContext } from '../i18n/FormplayerLocaleContext'; @@ -134,6 +135,23 @@ const FinalizeRenderer = ({ data }: ControlProps) => { return value; case 'adate': return displayAdate(value); + case 'likert': { + if (value === null) + return t('finalize.value.notApplicable', 'Not applicable'); + const oneOf = fieldSchema?.oneOf; + if (Array.isArray(oneOf)) { + const match = oneOf.find( + (o: { const?: unknown; title?: string }) => o.const === value, + ); + if (match?.title) return match.title; + } + return String(value); + } + case 'duration': + if (typeof value === 'number' && !Number.isNaN(value)) { + return formatDurationHuman(value); + } + return t('finalize.notProvided', 'Not provided'); } } diff --git a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx new file mode 100644 index 000000000..e966dd151 --- /dev/null +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx @@ -0,0 +1,386 @@ +// @vitest-environment jsdom +import React, { useState } from 'react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + render, + screen, + cleanup, + fireEvent, + waitFor, +} from '@testing-library/react'; +import { ThemeProvider } from '@mui/material/styles'; +import { JsonForms } from '@jsonforms/react'; +import type { UISchemaElement } from '@jsonforms/core'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import Ajv from 'ajv'; +import { theme } from '../theme/theme'; +import { FormContext } from '../App'; +import LikertScaleQuestionRenderer, { + likertScaleQuestionTester, +} from './LikertScaleQuestionRenderer'; +import LikertScaleControl from '../components/likert/LikertScaleControl'; +import { + resolveLikertOptions, + injectLikertNotApplicable, +} from '../components/likert/likertConfig'; +import type { + LikertJsonSchema, + LikertObjectJsonSchema, +} from '../components/likert/likertTypes'; +import { shellMaterialRenderers } from '../theme/material-wrappers'; + +const ajv = new Ajv({ allErrors: true, strict: false }); +ajv.addFormat('likert', () => true); + +const likertFieldSchema: LikertJsonSchema = { + type: 'integer', + format: 'likert', + title: 'Service satisfaction', + oneOf: [ + { const: 1, title: 'Very dissatisfied' }, + { const: 2, title: 'Dissatisfied' }, + { const: 3, title: 'Neutral' }, + { const: 4, title: 'Satisfied' }, + { const: 5, title: 'Very satisfied' }, + ], + likert: { + display: 'buttons', + colorMode: 'spectrum', + allowClear: true, + }, +}; + +const satisfactionSchema: LikertObjectJsonSchema = { + type: 'object', + properties: { + satisfaction: likertFieldSchema, + }, +}; + +const satisfactionUischema: UISchemaElement = { + type: 'Control', + scope: '#/properties/satisfaction', +}; + +const productionRenderers = [ + ...shellMaterialRenderers, + ...materialRenderers, + { tester: likertScaleQuestionTester, renderer: LikertScaleQuestionRenderer }, +]; + +function LikertIntegrationHarness({ + initialData = {}, + uischema = satisfactionUischema, +}: { + initialData?: Record; + uischema?: UISchemaElement; +}) { + const [data, setData] = useState>(initialData); + return ( + + + setData(d || {})} + /> +
{JSON.stringify(data)}
+
+
+ ); +} + +function readCommittedData(): Record { + return JSON.parse(screen.getByTestId('committed-data').textContent || '{}'); +} + +afterEach(() => cleanup()); + +describe('likertScaleQuestionTester', () => { + it('matches schema with format likert', () => { + const rank = likertScaleQuestionTester( + satisfactionUischema, + satisfactionSchema, + { rootSchema: satisfactionSchema, config: {} } as never, + ); + expect(rank).toBe(12); + }); +}); + +describe('LikertScaleControl', () => { + it('renders scale options from oneOf', () => { + render( + + {}} + resolved={resolveLikertOptions(likertFieldSchema)} + enabled + hasError={false} + /> + , + ); + expect(screen.getByText('Very dissatisfied')).toBeTruthy(); + expect(screen.getByText('Very satisfied')).toBeTruthy(); + }); + + it('selects and clears an option', () => { + const onChange = vi.fn(); + render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Satisfied' })); + expect(onChange).toHaveBeenCalledWith(undefined); + }); + + it('excludes N/A when selecting a scale value', () => { + const onChange = vi.fn(); + const schemaWithNa: LikertJsonSchema = { + ...likertFieldSchema, + type: ['integer', 'null'], + likert: { + ...likertFieldSchema.likert, + allowNotApplicable: true, + notApplicableLabel: 'Not applicable', + }, + }; + render( + + + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Neutral' })); + expect(onChange).toHaveBeenCalledWith(3); + }); +}); + +describe('LikertScaleQuestionRenderer integration', () => { + it('renders via JsonForms with production renderer order', () => { + render(); + expect(screen.getByText('Very dissatisfied')).toBeTruthy(); + expect(screen.getByText('Service satisfaction')).toBeTruthy(); + }); + + it('commits the selected oneOf const to observation data', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Satisfied' })); + await waitFor(() => { + expect(readCommittedData()).toEqual({ satisfaction: 4 }); + }); + }); + + it('clears a re-selected value when allowClear is enabled', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Satisfied' })); + await waitFor(() => { + expect(readCommittedData()).toEqual({}); + }); + }); +}); + +describe('resolveLikertOptions', () => { + it('uses preset when oneOf is omitted', () => { + const resolved = resolveLikertOptions({ + type: 'integer', + format: 'likert', + likert: { preset: 'agreement' }, + } as LikertJsonSchema); + expect(resolved.options).toHaveLength(5); + expect(resolved.options[0].label).toBe('Strongly disagree'); + }); + + it('defaults to horizontal layout when orientation is omitted', () => { + const resolved = resolveLikertOptions(likertFieldSchema); + expect(resolved.layout).toEqual({ mode: 'horizontal' }); + }); + + it('parses cols-2 layout from ui options', () => { + const resolved = resolveLikertOptions(likertFieldSchema, { + options: { orientation: 'cols-2' }, + }); + expect(resolved.layout).toEqual({ mode: 'columns', columns: 2 }); + }); + + it('overrides option labels from ui.json options.oneOf (translations)', () => { + const resolved = resolveLikertOptions(likertFieldSchema, { + options: { + oneOf: [ + { const: 1, title: 'Muito insatisfeito' }, + { const: 5, title: 'Muito satisfeito' }, + ], + }, + }); + expect(resolved.options[0].label).toBe('Muito insatisfeito'); + expect(resolved.options[4].label).toBe('Muito satisfeito'); + // Untranslated entries keep their schema label. + expect(resolved.options[2].label).toBe('Neutral'); + }); + + it('builds options from ui.json options.oneOf when schema omits oneOf', () => { + const resolved = resolveLikertOptions( + { type: 'integer', format: 'likert' } as LikertJsonSchema, + { + options: { + oneOf: [ + { const: 1, title: 'Baixo' }, + { const: 2, title: 'Alto' }, + ], + }, + }, + ); + expect(resolved.options.map(o => o.label)).toEqual(['Baixo', 'Alto']); + }); + + it('excludes the N/A value from displayed scale options', () => { + const resolved = resolveLikertOptions({ + type: ['integer', 'null'], + format: 'likert', + oneOf: [ + { const: 1, title: 'Not important' }, + { const: 2, title: 'Important' }, + { const: null, title: 'Not applicable' }, + ], + likert: { allowNotApplicable: true, notApplicableValue: null }, + } as unknown as LikertJsonSchema); + expect(resolved.options.map(o => o.value)).toEqual([1, 2]); + expect(resolved.allowNotApplicable).toBe(true); + }); +}); + +describe('injectLikertNotApplicable', () => { + const naSchema: LikertObjectJsonSchema = { + type: 'object', + properties: { + importance: { + type: ['integer', 'null'], + format: 'likert', + title: 'How important is this feature?', + oneOf: [ + { const: 1, title: 'Not important' }, + { const: 5, title: 'Very important' }, + ], + likert: { allowNotApplicable: true, notApplicableValue: null }, + } as unknown as LikertJsonSchema, + }, + }; + + it('adds a matching oneOf branch so the N/A value validates', () => { + const validate = new Ajv({ allErrors: true, strict: false }); + validate.addFormat('likert', () => true); + + // Original schema rejects the N/A (null) value. + expect(validate.validate(naSchema, { importance: null })).toBe(false); + + const normalized = injectLikertNotApplicable(naSchema); + const validateNormalized = new Ajv({ allErrors: true, strict: false }); + validateNormalized.addFormat('likert', () => true); + + expect(validateNormalized.validate(normalized, { importance: null })).toBe( + true, + ); + expect(validateNormalized.validate(normalized, { importance: 5 })).toBe( + true, + ); + }); + + it('does not mutate the original schema', () => { + const before = JSON.stringify(naSchema); + injectLikertNotApplicable(naSchema); + expect(JSON.stringify(naSchema)).toBe(before); + }); + + it('ensures type allows null for preset-based N/A fields without oneOf', () => { + const normalized = injectLikertNotApplicable({ + type: 'object', + properties: { + freq: { + type: 'integer', + format: 'likert', + likert: { preset: 'frequency', allowNotApplicable: true }, + }, + }, + }) as { properties: { freq: { type: unknown } } }; + expect(normalized.properties.freq.type).toEqual(['integer', 'null']); + }); +}); + +describe('LikertScaleControl display variants', () => { + const emojiSchema: LikertJsonSchema = { + type: 'integer', + format: 'likert', + title: 'How do you feel?', + oneOf: [ + { const: 1, title: 'Very bad', emoji: '😞' }, + { const: 2, title: 'Okay', emoji: '😐' }, + { const: 3, title: 'Great', emoji: '😄' }, + ], + likert: { display: 'emoji' }, + }; + + it('always pairs emoji with its text label', () => { + render( + + {}} + resolved={resolveLikertOptions(emojiSchema)} + enabled + hasError={false} + /> + , + ); + expect(screen.getByText('Very bad')).toBeTruthy(); + expect(screen.getByText('Okay')).toBeTruthy(); + expect(screen.getByText('Great')).toBeTruthy(); + }); + + it('shows verbal endpoint anchors on a numeric scale', () => { + const numericSchema: LikertJsonSchema = { + type: 'integer', + format: 'likert', + title: 'Pain level', + oneOf: [ + { const: 0, title: 'No pain' }, + { const: 1, title: '1' }, + { const: 2, title: '2' }, + { const: 3, title: 'Worst pain' }, + ], + likert: { display: 'numeric' }, + }; + render( + + {}} + resolved={resolveLikertOptions(numericSchema)} + enabled + hasError={false} + /> + , + ); + expect(screen.getByText('No pain')).toBeTruthy(); + expect(screen.getByText('Worst pain')).toBeTruthy(); + }); +}); diff --git a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx new file mode 100644 index 000000000..ca5b7067e --- /dev/null +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { ControlProps, rankWith, schemaMatches } from '@jsonforms/core'; +import QuestionShell from '../components/QuestionShell'; +import LikertScaleControl from '../components/likert/LikertScaleControl'; +import { resolveLikertOptions } from '../components/likert/likertConfig'; +import { formatControlErrors } from '../utils/formatControlErrors'; + +export const likertScaleQuestionTester = rankWith( + 12, + schemaMatches( + schema => + typeof schema === 'object' && + schema !== null && + (schema as { format?: string }).format === 'likert', + ), +); + +const LikertScaleQuestionRenderer: React.FC = ({ + data, + handleChange, + path, + errors, + schema, + uischema, + enabled = true, + visible = true, + label, + required, +}) => { + const resolved = resolveLikertOptions( + schema, + uischema as { options?: Record }, + ); + const hasErrors = Boolean( + errors && (Array.isArray(errors) ? errors.length > 0 : true), + ); + const errorMessage = formatControlErrors( + hasErrors + ? Array.isArray(errors) + ? errors.map((e: { message?: string } | string) => + typeof e === 'object' && e && 'message' in e && e.message + ? String(e.message) + : String(e), + ) + : errors + : null, + ); + + if (visible === false) { + return null; + } + + const title = + (uischema as { label?: string })?.label ?? label ?? schema.title ?? ''; + const description = schema.description; + + return ( + + handleChange(path, newValue)} + resolved={resolved} + enabled={enabled} + hasError={Boolean(errorMessage)} + /> + + ); +}; + +export default withJsonFormsControlProps(LikertScaleQuestionRenderer); diff --git a/formulus-formplayer/src/stories/DurationQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/DurationQuestionRenderer.stories.tsx new file mode 100644 index 000000000..94e804a58 --- /dev/null +++ b/formulus-formplayer/src/stories/DurationQuestionRenderer.stories.tsx @@ -0,0 +1,134 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; +import DurationQuestionRenderer, { + durationQuestionTester, +} from '../renderers/DurationQuestionRenderer'; +import { materialRenderers } from '@jsonforms/material-renderers'; + +const renderers = [ + { tester: durationQuestionTester, renderer: DurationQuestionRenderer }, + ...materialRenderers, +]; + +const meta: Meta = { + title: 'Question Renderers/DurationQuestionRenderer', + component: JsonFormsControlWrapper, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const uischema = { + type: 'Control', + scope: '#/properties/task_duration', +}; + +export const StopwatchIdle: Story = { + args: { + schema: { + type: 'object', + properties: { + task_duration: { + type: 'number', + format: 'duration', + title: 'Time to complete the task', + minimum: 0, + duration: { + mode: 'stopwatch', + unit: 'seconds', + precision: 1, + allowManualEntry: true, + }, + }, + }, + }, + uischema, + initialData: {}, + renderers, + }, +}; + +export const StopwatchSaved: Story = { + args: { + schema: { + type: 'object', + properties: { + task_duration: { + type: 'number', + format: 'duration', + title: 'Time to complete the task', + duration: { mode: 'stopwatch', precision: 1 }, + }, + }, + }, + uischema, + initialData: { task_duration: 83.4 }, + renderers, + }, +}; + +export const Countdown: Story = { + args: { + schema: { + type: 'object', + properties: { + task_duration: { + type: 'number', + format: 'duration', + title: 'Hold position duration', + duration: { + mode: 'countdown', + countdownFrom: 60, + precision: 1, + allowManualEntry: false, + }, + }, + }, + }, + uischema, + initialData: {}, + renderers, + }, +}; + +export const ManualOnly: Story = { + args: { + schema: { + type: 'object', + properties: { + task_duration: { + type: 'number', + format: 'duration', + title: 'Enter elapsed time (seconds)', + duration: { mode: 'manual', precision: 1 }, + }, + }, + }, + uischema, + initialData: {}, + renderers, + }, +}; + +export const RequiredError: Story = { + args: { + schema: { + type: 'object', + required: ['task_duration'], + properties: { + task_duration: { + type: 'number', + format: 'duration', + title: 'Time to complete the task', + duration: { mode: 'stopwatch' }, + }, + }, + }, + uischema, + initialData: {}, + renderers, + validationMode: 'ValidateAndShow', + }, +}; diff --git a/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx new file mode 100644 index 000000000..8c25be774 --- /dev/null +++ b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx @@ -0,0 +1,427 @@ +import React from 'react'; +import type { Meta, StoryObj } from '@storybook/react-vite'; +import { JsonFormsControlWrapper } from './JsonFormsControlWrapper'; +import LikertScaleQuestionRenderer, { + likertScaleQuestionTester, +} from '../renderers/LikertScaleQuestionRenderer'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import type { + LikertConfig, + LikertOneOfEntry, +} from '../components/likert/likertTypes'; +import { + likertField, + likertObjectSchema, + likertPresetField, +} from '../components/likert/likertSchemaHelpers'; + +const satisfactionOneOf: LikertOneOfEntry[] = [ + { const: 1, title: 'Very dissatisfied' }, + { const: 2, title: 'Dissatisfied' }, + { const: 3, title: 'Neutral' }, + { const: 4, title: 'Satisfied' }, + { const: 5, title: 'Very satisfied' }, +]; + +const npsOneOf: LikertOneOfEntry[] = Array.from({ length: 11 }, (_, i) => ({ + const: i, + title: + i === 0 ? 'Not at all likely' : i === 10 ? 'Extremely likely' : String(i), +})); + +const renderers = [ + { tester: likertScaleQuestionTester, renderer: LikertScaleQuestionRenderer }, + ...materialRenderers, +]; + +/** Simulates a phone-width container to check wrapping and label fit. */ +const mobileDecorator = (Story: React.ComponentType) => ( +
+ +
+); + +const meta: Meta = { + title: 'Question Renderers/LikertScaleQuestionRenderer', + component: JsonFormsControlWrapper, + parameters: { layout: 'centered' }, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +function satisfactionSchema( + likert: LikertConfig = {}, + fieldOverrides: Omit< + NonNullable[1]>, + 'likert' + > = {}, +) { + return likertObjectSchema( + likertField(satisfactionOneOf, { + title: 'How satisfied are you with the service?', + likert: { display: 'buttons', ...likert }, + ...fieldOverrides, + }), + ); +} + +const uischema = { + type: 'Control', + scope: '#/properties/satisfaction', +}; + +// --- Display variants ------------------------------------------------------- + +export const Buttons: Story = { + args: { schema: satisfactionSchema(), uischema, initialData: {}, renderers }, +}; + +export const ButtonsSelected: Story = { + args: { + schema: satisfactionSchema(), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const ButtonsSpectrum: Story = { + args: { + schema: satisfactionSchema({ colorMode: 'spectrum' }), + uischema, + initialData: { satisfaction: 1 }, + renderers, + }, +}; + +export const RadioRow: Story = { + args: { + schema: satisfactionSchema({ display: 'radio' }), + uischema, + initialData: { satisfaction: 3 }, + renderers, + }, +}; + +export const Slider: Story = { + args: { + schema: likertObjectSchema( + likertField(npsOneOf, { + title: 'Rate your experience (0–10)', + likert: { display: 'slider' }, + }), + ), + uischema, + initialData: { satisfaction: 7 }, + renderers, + }, +}; + +export const NumericScale: Story = { + args: { + schema: likertObjectSchema( + likertField( + Array.from({ length: 5 }, (_, i) => ({ + const: i + 1, + title: String(i + 1), + })), + { + title: 'On a scale of 1–5, how likely are you to return?', + likert: { display: 'numeric' }, + }, + ), + ), + uischema, + initialData: { satisfaction: 2 }, + renderers, + }, +}; + +export const NumericWithWordAnchors: Story = { + name: 'Numeric + word anchors (recommended)', + args: { + schema: likertObjectSchema( + likertField( + [ + { const: 0, title: 'No pain' }, + { const: 1, title: '1' }, + { const: 2, title: '2' }, + { const: 3, title: '3' }, + { const: 4, title: '4' }, + { const: 5, title: '5' }, + { const: 6, title: '6' }, + { const: 7, title: '7' }, + { const: 8, title: '8' }, + { const: 9, title: '9' }, + { const: 10, title: 'Worst pain' }, + ], + { + title: 'Rate your pain level', + likert: { display: 'numeric', colorMode: 'spectrum' }, + }, + ), + ), + uischema, + initialData: { satisfaction: 3 }, + renderers, + }, +}; + +export const Stars: Story = { + args: { + schema: satisfactionSchema({ display: 'stars' }), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const Emoji: Story = { + args: { + schema: likertObjectSchema( + likertField( + [ + { const: 1, title: 'Very bad', emoji: '😞' }, + { const: 2, title: 'Bad', emoji: '😕' }, + { const: 3, title: 'Okay', emoji: '😐' }, + { const: 4, title: 'Good', emoji: '🙂' }, + { const: 5, title: 'Great', emoji: '😄' }, + ], + { + title: 'How do you feel today?', + likert: { display: 'emoji' }, + }, + ), + ), + uischema, + initialData: { satisfaction: 5 }, + renderers, + }, +}; + +// --- Scale configuration ---------------------------------------------------- + +export const NpsEndpointLabelsOnly: Story = { + name: 'NPS 0–10 (endpoint labels only)', + args: { + schema: likertObjectSchema( + likertField(npsOneOf, { + title: 'How likely are you to recommend us?', + likert: { display: 'buttons', endpointLabelsOnly: true }, + }), + ), + uischema, + initialData: { satisfaction: 8 }, + renderers, + }, +}; + +export const PresetAgreement: Story = { + args: { + schema: likertObjectSchema( + likertPresetField('agreement', { + title: 'I would recommend this service', + likert: { display: 'buttons' }, + }), + 'agreement', + ), + uischema: { type: 'Control', scope: '#/properties/agreement' }, + initialData: {}, + renderers, + }, +}; + +export const WithNotApplicable: Story = { + args: { + schema: satisfactionSchema( + { + allowNotApplicable: true, + notApplicableLabel: 'Not applicable', + }, + { type: ['integer', 'null'] }, + ), + uischema, + initialData: { satisfaction: null }, + renderers, + }, +}; + +export const StackedVertical: Story = { + args: { + schema: satisfactionSchema(), + uischema: { ...uischema, options: { orientation: 'vertical' } }, + initialData: {}, + renderers, + }, +}; + +export const TwoColumnLayout: Story = { + name: 'Two-column layout (cols-2)', + args: { + schema: satisfactionSchema(), + uischema: { ...uischema, options: { orientation: 'cols-2' } }, + initialData: { satisfaction: 3 }, + renderers, + }, +}; + +// --- States ----------------------------------------------------------------- + +export const RequiredError: Story = { + args: { + schema: likertObjectSchema( + likertField(satisfactionOneOf, { + title: 'How satisfied are you with the service?', + likert: { display: 'buttons' }, + }), + 'satisfaction', + ['satisfaction'], + ), + uischema, + initialData: {}, + renderers, + validationMode: 'ValidateAndShow', + }, +}; + +export const Disabled: Story = { + args: { + schema: satisfactionSchema(), + uischema: { ...uischema, options: { readonly: true } }, + initialData: { satisfaction: 3 }, + renderers, + }, +}; + +export const ReadOnlyReview: Story = { + name: 'Readonly / review mode', + args: { + schema: satisfactionSchema({ colorMode: 'spectrum' }), + uischema: { ...uischema, options: { readonly: true } }, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const ReadOnlyReviewNumeric: Story = { + name: 'Readonly / review (numeric + anchors)', + args: { + schema: likertObjectSchema( + likertField( + [ + { const: 0, title: 'No pain' }, + { const: 1, title: '1' }, + { const: 2, title: '2' }, + { const: 3, title: '3' }, + { const: 4, title: '4' }, + { const: 5, title: '5' }, + { const: 6, title: '6' }, + { const: 7, title: '7' }, + { const: 8, title: '8' }, + { const: 9, title: '9' }, + { const: 10, title: 'Worst pain' }, + ], + { + title: 'Rate your pain level', + likert: { display: 'numeric', colorMode: 'spectrum' }, + }, + ), + ), + uischema: { + type: 'Control', + scope: '#/properties/satisfaction', + options: { readonly: true }, + }, + initialData: { satisfaction: 7 }, + renderers, + }, +}; + +export const EmojiSpectrum: Story = { + name: 'Emoji + spectrum accent', + args: { + schema: likertObjectSchema( + likertField( + [ + { const: 1, title: 'Very bad', emoji: '😞' }, + { const: 2, title: 'Bad', emoji: '😕' }, + { const: 3, title: 'Okay', emoji: '😐' }, + { const: 4, title: 'Good', emoji: '🙂' }, + { const: 5, title: 'Great', emoji: '😄' }, + ], + { + title: 'How do you feel today?', + likert: { display: 'emoji', colorMode: 'spectrum' }, + }, + ), + ), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const TranslatedLabels: Story = { + args: { + schema: likertObjectSchema( + likertField( + [ + { const: 1, title: 'Très insatisfait' }, + { const: 2, title: 'Insatisfait' }, + { const: 3, title: 'Neutre' }, + { const: 4, title: 'Satisfait' }, + { const: 5, title: 'Très satisfait' }, + ], + { + title: 'Quelle est votre satisfaction?', + likert: { display: 'buttons' }, + }, + ), + ), + uischema, + initialData: {}, + renderers, + }, +}; + +// --- Mobile / narrow layouts ------------------------------------------------- + +export const MobileButtons: Story = { + name: 'Mobile width: buttons wrap', + decorators: [mobileDecorator], + args: { + schema: satisfactionSchema(), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const MobileNps: Story = { + name: 'Mobile width: NPS 0–10', + decorators: [mobileDecorator], + args: { + schema: likertObjectSchema( + likertField(npsOneOf, { + title: 'How likely are you to recommend us?', + likert: { display: 'buttons', endpointLabelsOnly: true }, + }), + ), + uischema, + initialData: { satisfaction: 6 }, + renderers, + }, +}; + +export const MobileRadioRow: Story = { + name: 'Mobile width: radio row', + decorators: [mobileDecorator], + args: { + schema: satisfactionSchema({ display: 'radio' }), + uischema, + initialData: {}, + renderers, + }, +};