From 0b95e624550259b9e3ba8fbf69dfd5ff47944bcf Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 2 Jul 2026 11:10:07 +0300 Subject: [PATCH 1/6] feat(formplayer): add Likert scale and duration question types Add built-in `likert` and `duration` formats to the formplayer (same pattern as photo/gps/signature, not app-bundle custom types). Likert scale (format: "likert"): - Displays: buttons, radio, slider, numeric, stars, emoji - Standard, clean look across all variants: neutral outlined cells with accent (border + tint + bold) only on the selected option - colorMode neutral | spectrum (semantic accent on selection) | stars - Presets, endpoint-labels-only, allowClear, allowNotApplicable - Responsive wrapping for tablet/phone widths Duration/timer (format: "duration"): - Stopwatch, countdown, manual modes; value stored as seconds - Explicit Save on stopwatch before commit Registered in App.tsx (customRenderers + addFormat) and FinalizeRenderer for readonly/review display. Includes unit tests and Storybook stories. --- formulus-formplayer/.storybook/preview.tsx | 2 +- formulus-formplayer/AGENTS.md | 26 +- formulus-formplayer/README.md | 77 ++++ formulus-formplayer/src/App.tsx | 10 + .../components/duration/DurationControl.tsx | 286 +++++++++++++ .../src/components/duration/durationFormat.ts | 65 +++ .../components/likert/LikertScaleControl.tsx | 401 ++++++++++++++++++ .../src/components/likert/likertColors.ts | 31 ++ .../src/components/likert/likertConfig.ts | 101 +++++ .../src/components/likert/likertPresets.ts | 55 +++ .../src/components/likert/likertTypes.ts | 53 +++ .../DurationQuestionRenderer.test.tsx | 153 +++++++ .../renderers/DurationQuestionRenderer.tsx | 71 ++++ .../src/renderers/FinalizeRenderer.tsx | 17 + .../LikertScaleQuestionRenderer.test.tsx | 178 ++++++++ .../renderers/LikertScaleQuestionRenderer.tsx | 75 ++++ .../DurationQuestionRenderer.stories.tsx | 134 ++++++ .../LikertScaleQuestionRenderer.stories.tsx | 354 ++++++++++++++++ 18 files changed, 2076 insertions(+), 13 deletions(-) create mode 100644 formulus-formplayer/src/components/duration/DurationControl.tsx create mode 100644 formulus-formplayer/src/components/duration/durationFormat.ts create mode 100644 formulus-formplayer/src/components/likert/LikertScaleControl.tsx create mode 100644 formulus-formplayer/src/components/likert/likertColors.ts create mode 100644 formulus-formplayer/src/components/likert/likertConfig.ts create mode 100644 formulus-formplayer/src/components/likert/likertPresets.ts create mode 100644 formulus-formplayer/src/components/likert/likertTypes.ts create mode 100644 formulus-formplayer/src/renderers/DurationQuestionRenderer.test.tsx create mode 100644 formulus-formplayer/src/renderers/DurationQuestionRenderer.tsx create mode 100644 formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx create mode 100644 formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.tsx create mode 100644 formulus-formplayer/src/stories/DurationQuestionRenderer.stories.tsx create mode 100644 formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx 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 ce0790119..b755897f6 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 @@ -59,6 +59,8 @@ This file gives AI assistants and developers enough context to work effectively 4. **Design tokens**: Use `@ode/tokens` via `src/theme/tokens-adapter.ts` and the theme in `src/theme/theme.ts`; avoid hardcoding colors/spacing that exist in tokens. 5. **Attachment-backed builtins** (`photo`, `audio`, `video`, `select_file`): Observation JSON stores **basename-only** `filename` plus portable metadata; RN writes files under **`attachments/draft/`** (etc.). Resolve previews with **`getAttachmentUri`** where applicable. **`select_file`** shows the chosen **name** only—no file preview. 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 d25e8e7e8..f3f8f1637 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -204,6 +204,83 @@ 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 + +These are **built-in** renderers (same pattern as `photo`, `gps`, `signature`) — not app-bundle `question_types/`. + +### Likert scale (`format: "likert"`) + +Use for agreement, satisfaction, frequency, and numeric rating scales. Stored value is the selected `oneOf[].const` (typically an integer). + +```json +{ + "type": "integer", + "format": "likert", + "title": "How satisfied are you with the service?", + "oneOf": [ + { "const": 1, "title": "Very dissatisfied" }, + { "const": 5, "title": "Very satisfied" } + ], + "likert": { + "preset": "satisfaction", + "display": "buttons", + "colorMode": "spectrum", + "allowClear": true, + "allowNotApplicable": false + } +} +``` + +All variants share one standard look: outlined neutral cells; the selected option gets an accent border + tint. Unselected options are never colored. + +| `likert.display` | UI | +| ---------------- | -------------------------------------------------- | +| `buttons` | Equal-width outlined option cells (default) | +| `radio` | Radio row with labels below (classic survey style) | +| `slider` | MUI slider with tick marks and endpoint labels | +| `numeric` | Compact number cells (NPS style) | +| `stars` | MUI `Rating` stars with selected label | +| `emoji` | Emoji per option (`emoji` on each `oneOf` entry) | + +| `likert.colorMode` | Selected-option accent | +| ------------------ | -------------------------------------------------- | +| `neutral` | Theme primary (default) | +| `spectrum` | Semantic red / yellow / green by scale position | +| `stars` | Standard MUI Rating gold (when `display: "stars"`) | + +Presets (when `oneOf` is omitted): `agreement`, `frequency`, `satisfaction`, `importance`, `likelihood`, `numeric_0_10`, `numeric_1_5`, `numeric_1_7`. + +UI layout: `ui.json` `options.orientation` (`horizontal` \| `vertical`), `options.display` overrides `likert.display`. + +Storybook: `Question Renderers/LikertScaleQuestionRenderer`. + +### Duration / timer (`format: "duration"`) + +Captures elapsed time as **seconds** (JSON number). Commit on explicit **Save** (stopwatch) or manual entry blur. + +```json +{ + "type": "number", + "format": "duration", + "title": "Time to complete the task", + "duration": { + "mode": "stopwatch", + "unit": "seconds", + "precision": 1, + "allowManualEntry": true, + "countdownFrom": null + } +} +``` + +| `duration.mode` | UI | +| --------------- | -------------------------------------- | +| `stopwatch` | Start / Pause / Resume / Reset / Save | +| `countdown` | Countdown from `countdownFrom` seconds | +| `manual` | Numeric seconds field only | + +Storybook: `Question Renderers/DurationQuestionRenderer`. + ## 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 7ea7fdb27..1a739dd28 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -88,6 +88,12 @@ import SubObservationQuestionRenderer, { } from './renderers/SubObservationQuestionRenderer'; import { shellMaterialRenderers } from './theme/material-wrappers'; import { numberStepperRenderer } from './renderers/NumberStepperRenderer'; +import LikertScaleQuestionRenderer, { + likertScaleQuestionTester, +} from './renderers/LikertScaleQuestionRenderer'; +import DurationQuestionRenderer, { + durationQuestionTester, +} from './renderers/DurationQuestionRenderer'; import DynamicEnumControl, { dynamicEnumTester } from './DynamicEnumControl'; import ShellInputControl, { shellInputControlTester, @@ -318,6 +324,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 }, { @@ -948,6 +956,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..a992e2599 --- /dev/null +++ b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx @@ -0,0 +1,401 @@ +import React, { useCallback } from 'react'; +import { + alpha, + Box, + Button, + ButtonBase, + FormControlLabel, + Radio, + RadioGroup, + Rating, + Slider, + Typography, + useTheme, +} from '@mui/material'; +import { getSpectrumColor } from './likertColors'; +import type { LikertOption, ResolvedLikertOptions } from './likertTypes'; +import { isNotApplicableValue, valuesEqual } from './likertConfig'; + +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} + + + ); +} + +export default function LikertScaleControl({ + value, + onChange, + resolved, + enabled, + hasError, +}: LikertScaleControlProps) { + const theme = useTheme(); + const { + options, + display, + colorMode, + endpointLabelsOnly, + allowClear, + allowNotApplicable, + notApplicableLabel, + notApplicableValue, + orientation, + } = resolved; + + const isNa = isNotApplicableValue(value, notApplicableValue); + const scaleValue = isNa ? null : value; + const vertical = orientation === 'vertical'; + + 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; + + /** + * One shared cell style for buttons / numeric / emoji so every Likert + * looks the same: outlined neutral cells, tinted accent when selected. + */ + const cellSx = (index: number, selected: boolean) => { + const accent = accentFor(index); + return { + minHeight: 44, + px: 1, + py: 0.75, + borderRadius: 1.5, + border: '1px solid', + borderColor: selected ? accent : neutralBorder, + backgroundColor: selected ? alpha(accent, 0.1) : 'transparent', + 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'], + { duration: theme.transitions.duration.shortest }, + ), + '&:hover': { + backgroundColor: selected + ? alpha(accent, 0.14) + : theme.palette.action.hover, + }, + '&.Mui-focusVisible': { + outline: `2px solid ${alpha(accent, 0.5)}`, + outlineOffset: 1, + }, + '&.Mui-disabled': { + color: theme.palette.text.disabled, + borderColor: theme.palette.divider, + }, + }; + }; + + const cellRowSx = { + display: 'flex', + flexDirection: vertical ? 'column' : 'row', + flexWrap: vertical ? 'nowrap' : 'wrap', + gap: 0.75, + } as const; + + /** + * Horizontal sizing per variant: + * - labeled cells share the row equally but wrap onto extra rows when a + * cell would drop below a readable width (long labels, narrow screens); + * - compact cells (numeric) keep a fixed square-ish footprint and wrap. + */ + const horizontalCellSx = (labelled: boolean) => + labelled + ? { flex: '1 1 88px', minWidth: 72 } + : { flex: '0 0 auto', minWidth: 48 }; + + const renderCells = ( + content: (opt: LikertOption, index: number) => React.ReactNode, + labelled: 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), + ...(vertical + ? { justifyContent: 'flex-start', textAlign: 'left', px: 1.5 } + : horizontalCellSx(labelled)), + }}> + {content(opt, index)} + + ); + })} + + {endpointLabelsOnly && !vertical && } + + ); + + const renderButtons = () => + renderCells( + opt => (endpointLabelsOnly ? String(opt.value) : opt.label), + !endpointLabelsOnly, + ); + + const renderNumeric = () => renderCells(opt => String(opt.value), false); + + const renderEmoji = () => + renderCells( + opt => ( + + + {opt.emoji ?? opt.label.charAt(0)} + + {!endpointLabelsOnly && ( + + {opt.label} + + )} + + ), + true, + ); + + const renderRadio = () => ( + + { + const opt = options.find(o => String(o.value) === e.target.value); + if (opt) handleSelect(opt.value); + }}> + + {options.map(opt => ( + } + labelPlacement={vertical ? 'end' : 'bottom'} + label={ + + {endpointLabelsOnly ? String(opt.value) : opt.label} + + } + sx={ + vertical + ? undefined + : { + flex: endpointLabelsOnly ? '0 1 48px' : '1 1 64px', + m: 0, + minWidth: endpointLabelsOnly ? 40 : 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="auto" + 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 && ( + + )} + + ); +} 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..e66791d6e --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertConfig.ts @@ -0,0 +1,101 @@ +import type { JsonSchema } from '@jsonforms/core'; +import { getPresetOptions } from './likertPresets'; +import type { + LikertConfig, + LikertDisplay, + LikertOption, + ResolvedLikertOptions, +} from './likertTypes'; +import { resolveEffectiveColorMode } from './likertColors'; + +type OneOfEntry = { + const?: unknown; + enum?: unknown[]; + title?: string; + emoji?: string; +}; + +function oneOfToOptions(oneOf: OneOfEntry[]): LikertOption[] { + return oneOf.map(entry => { + const value = + entry.const !== undefined + ? (entry.const as string | number) + : (entry.enum?.[0] as string | number); + return { + value, + label: entry.title ?? String(value), + 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); + } + + const uiDisplay = uiOptions.display as LikertDisplay | undefined; + const display = + uiDisplay ?? config.display ?? (options.length > 7 ? 'slider' : 'buttons'); + + const orientation = + uiOptions.orientation === 'vertical' ? 'vertical' : 'horizontal'; + + const colorMode = resolveEffectiveColorMode(display, config.colorMode); + + return { + options, + display, + colorMode, + endpointLabelsOnly: + config.endpointLabelsOnly ?? uiOptions.endpointLabelsOnly === true, + allowClear: config.allowClear !== false, + allowNotApplicable: config.allowNotApplicable === true, + notApplicableLabel: config.notApplicableLabel ?? 'Not applicable', + notApplicableValue: + config.notApplicableValue !== undefined + ? config.notApplicableValue + : null, + orientation, + }; +} + +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); +} 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/likertTypes.ts b/formulus-formplayer/src/components/likert/likertTypes.ts new file mode 100644 index 000000000..17fa4228b --- /dev/null +++ b/formulus-formplayer/src/components/likert/likertTypes.ts @@ -0,0 +1,53 @@ +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'; + +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; + orientation: 'horizontal' | 'vertical'; +} + +/** JSON Schema field with optional Likert extension (formplayer built-in). */ +export type LikertJsonSchema = import('@jsonforms/core').JsonSchema7 & { + likert?: LikertConfig; +}; 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 6b5d5d2cc..753fad1e7 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'; interface SummaryItem { label: string; @@ -100,6 +101,22 @@ const FinalizeRenderer = ({ data }: ControlProps) => { return value; case 'adate': return displayAdate(value); + case 'likert': { + if (value === null) return '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 '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..219cd9c93 --- /dev/null +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx @@ -0,0 +1,178 @@ +// @vitest-environment jsdom +import React, { useState } from 'react'; +import { describe, it, expect, afterEach, vi } 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 LikertScaleQuestionRenderer, { + likertScaleQuestionTester, +} from './LikertScaleQuestionRenderer'; +import LikertScaleControl from '../components/likert/LikertScaleControl'; +import { resolveLikertOptions } from '../components/likert/likertConfig'; +import type { LikertJsonSchema } 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: JsonSchema7 = { + type: 'object', + properties: { + satisfaction: likertFieldSchema, + }, +}; + +const satisfactionUischema: UISchemaElement = { + type: 'Control', + scope: '#/properties/satisfaction', +}; + +const productionRenderers = [ + ...shellMaterialRenderers, + ...materialRenderers, + { tester: likertScaleQuestionTester, renderer: LikertScaleQuestionRenderer }, +]; + +function LikertIntegrationHarness({ + initialData = {}, +}: { + initialData?: Record; +}) { + const [data, setData] = useState>(initialData); + return ( + + + setData(d || {})} + /> + + + ); +} + +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(); + }); +}); + +describe('resolveLikertOptions', () => { + it('uses preset when oneOf is omitted', async () => { + 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'); + }); +}); 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..0d3f0a1da --- /dev/null +++ b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx @@ -0,0 +1,354 @@ +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'; + +const satisfactionOneOf = [ + { const: 1, title: 'Very dissatisfied' }, + { const: 2, title: 'Dissatisfied' }, + { const: 3, title: 'Neutral' }, + { const: 4, title: 'Satisfied' }, + { const: 5, title: 'Very satisfied' }, +]; + +const npsOneOf = 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 likertSchema( + overrides: Record = {}, + likert: Record = {}, +) { + return { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'How satisfied are you with the service?', + oneOf: satisfactionOneOf, + likert: { display: 'buttons', ...likert }, + ...overrides, + }, + }, + }; +} + +const uischema = { + type: 'Control', + scope: '#/properties/satisfaction', +}; + +// --- Display variants ------------------------------------------------------- + +export const Buttons: Story = { + args: { + schema: likertSchema(), + uischema, + initialData: {}, + renderers, + }, +}; + +export const ButtonsSelected: Story = { + args: { + schema: likertSchema(), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const ButtonsSpectrum: Story = { + args: { + schema: likertSchema({}, { colorMode: 'spectrum' }), + uischema, + initialData: { satisfaction: 1 }, + renderers, + }, +}; + +export const RadioRow: Story = { + args: { + schema: likertSchema({}, { display: 'radio' }), + uischema, + initialData: { satisfaction: 3 }, + renderers, + }, +}; + +export const Slider: Story = { + args: { + schema: { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'Rate your experience (0–10)', + oneOf: npsOneOf, + likert: { display: 'slider' }, + }, + }, + }, + uischema, + initialData: { satisfaction: 7 }, + renderers, + }, +}; + +export const NumericScale: Story = { + args: { + schema: { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'On a scale of 1–5, how likely are you to return?', + oneOf: Array.from({ length: 5 }, (_, i) => ({ + const: i + 1, + title: String(i + 1), + })), + likert: { display: 'numeric' }, + }, + }, + }, + uischema, + initialData: { satisfaction: 2 }, + renderers, + }, +}; + +export const Stars: Story = { + args: { + schema: likertSchema({}, { display: 'stars' }), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const Emoji: Story = { + args: { + schema: { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'How do you feel today?', + oneOf: [ + { 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: '😄' }, + ], + likert: { display: 'emoji' }, + }, + }, + }, + uischema, + initialData: { satisfaction: 5 }, + renderers, + }, +}; + +// --- Scale configuration ---------------------------------------------------- + +export const NpsEndpointLabelsOnly: Story = { + name: 'NPS 0–10 (endpoint labels only)', + args: { + schema: { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'How likely are you to recommend us?', + oneOf: npsOneOf, + likert: { display: 'buttons', endpointLabelsOnly: true }, + }, + }, + }, + uischema, + initialData: { satisfaction: 8 }, + renderers, + }, +}; + +export const PresetAgreement: Story = { + args: { + schema: { + type: 'object', + properties: { + agreement: { + type: 'integer', + format: 'likert', + title: 'I would recommend this service', + likert: { preset: 'agreement', display: 'buttons' }, + }, + }, + }, + uischema: { + type: 'Control', + scope: '#/properties/agreement', + }, + initialData: {}, + renderers, + }, +}; + +export const WithNotApplicable: Story = { + args: { + schema: likertSchema( + { type: ['integer', 'null'] }, + { + allowNotApplicable: true, + notApplicableLabel: 'Not applicable', + }, + ), + uischema, + initialData: { satisfaction: null }, + renderers, + }, +}; + +export const StackedVertical: Story = { + args: { + schema: likertSchema(), + uischema: { + ...uischema, + options: { orientation: 'vertical' }, + }, + initialData: {}, + renderers, + }, +}; + +// --- States ----------------------------------------------------------------- + +export const RequiredError: Story = { + args: { + schema: { + ...likertSchema(), + required: ['satisfaction'], + }, + uischema, + initialData: {}, + renderers, + validationMode: 'ValidateAndShow', + }, +}; + +export const Disabled: Story = { + args: { + schema: likertSchema(), + uischema: { + ...uischema, + options: { readonly: true }, + }, + initialData: { satisfaction: 3 }, + renderers, + }, +}; + +export const TranslatedLabels: Story = { + args: { + schema: { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'Quelle est votre satisfaction?', + oneOf: [ + { const: 1, title: 'Très insatisfait' }, + { const: 2, title: 'Insatisfait' }, + { const: 3, title: 'Neutre' }, + { const: 4, title: 'Satisfait' }, + { const: 5, title: 'Très satisfait' }, + ], + likert: { display: 'buttons' }, + }, + }, + }, + uischema, + initialData: {}, + renderers, + }, +}; + +// --- Mobile / narrow layouts ------------------------------------------------- + +export const MobileButtons: Story = { + name: 'Mobile width: buttons wrap', + decorators: [mobileDecorator], + args: { + schema: likertSchema(), + uischema, + initialData: { satisfaction: 4 }, + renderers, + }, +}; + +export const MobileNps: Story = { + name: 'Mobile width: NPS 0–10', + decorators: [mobileDecorator], + args: { + schema: { + type: 'object', + properties: { + satisfaction: { + type: 'integer', + format: 'likert', + title: 'How likely are you to recommend us?', + oneOf: npsOneOf, + likert: { display: 'buttons', endpointLabelsOnly: true }, + }, + }, + }, + uischema, + initialData: { satisfaction: 6 }, + renderers, + }, +}; + +export const MobileRadioRow: Story = { + name: 'Mobile width: radio row', + decorators: [mobileDecorator], + args: { + schema: likertSchema({}, { display: 'radio' }), + uischema, + initialData: {}, + renderers, + }, +}; From 1e0bf599af6d037f6fcbe8df4067d4e37f8966fd Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 2 Jul 2026 11:55:14 +0300 Subject: [PATCH 2/6] refactor(formplayer): polish Likert UX and fix schema typing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Standard survey styling: pill affordance, slider value badge (n/N), inline N/A with icon, review-mode de-emphasis of unselected options - Responsive layouts: phone stacking, cols-2…cols-5 via choiceLayout, default horizontal when orientation omitted - Numeric scales auto-show verbal endpoint anchors when configured - Emoji always paired with text label; spectrum accent on selection - Add LikertJsonSchema/LikertOneOfEntry types and schema helpers for Storybook/tests; fix TS2353 errors on likert/emoji properties - Stronger integration tests asserting committed observation JSON - Storybook: readonly review, two-column layout, mobile variants --- formulus-formplayer/README.md | 19 +- .../components/likert/LikertScaleControl.tsx | 352 ++++++++++++------ .../src/components/likert/likertConfig.ts | 33 +- .../components/likert/likertSchemaHelpers.ts | 50 +++ .../src/components/likert/likertTypes.ts | 25 +- .../LikertScaleQuestionRenderer.test.tsx | 109 +++++- .../LikertScaleQuestionRenderer.stories.tsx | 341 ++++++++++------- 7 files changed, 646 insertions(+), 283 deletions(-) create mode 100644 formulus-formplayer/src/components/likert/likertSchemaHelpers.ts diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index f3f8f1637..0a88fb3be 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -250,7 +250,24 @@ All variants share one standard look: outlined neutral cells; the selected optio Presets (when `oneOf` is omitted): `agreement`, `frequency`, `satisfaction`, `importance`, `likelihood`, `numeric_0_10`, `numeric_1_5`, `numeric_1_7`. -UI layout: `ui.json` `options.orientation` (`horizontal` \| `vertical`), `options.display` overrides `likert.display`. +**Recommended for rating scales:** use `display: "numeric"` and give the first/last `oneOf` entries verbal `title`s (e.g. `0` → "No pain", `10` → "Worst pain"). The control renders the numbers in cells with the words as endpoint anchors below — the highest-reliability, most practitioner-preferred pattern. For opinion questions, word `buttons` are fastest to answer. Emoji always render with their text label to avoid interpretive ambiguity. + +**Responsive layout (tablet + phone):** word/button and radio scales lay out as a row on tablets/desktop and automatically stack to one option per row on phones for readability; numeric/emoji cells wrap in an even grid. Set `ui.json` `options.orientation` to `vertical` (stacked), `horizontal`, `flow` (wrap), or `cols-2` … `cols-5` (two-column grid on tablets). Every option is a ≥44px touch target. `options.display` overrides `likert.display`. + +**Label guidance (form authors):** + +| Display | When to use | Label pattern | +| -------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | +| `buttons` / `radio` | Opinion scales (3–5 options) | Full label per option (`oneOf[].title`) | +| `numeric` | NPS, pain, rating (5+ points) | Numbers in cells; word labels on first/last `oneOf` entries only | +| `buttons` + `endpointLabelsOnly: true` | NPS 0–10 in button form | Digits in cells; endpoint words below | +| `slider` | Continuous 0–10 ranges | Endpoint word anchors below; value badge always visible (`7/10`) | +| `emoji` | Optional sentiment (low-stakes) | Emoji + text label on every option (never emoji-only) | +| `stars` | 5-point satisfaction | Star count + selected label beside control | + +Set `likert.endpointLabelsOnly: true` explicitly for long numeric scales. For 3–4 option scales, omit it so every option shows its full label. + +**Not applicable:** set `likert.allowNotApplicable: true` and use `type: ["integer", "null"]`. The N/A option appears inline at the end of button/numeric/emoji rows (dashed pill with ⊘ icon), or below for radio/slider/stars. Storybook: `Question Renderers/LikertScaleQuestionRenderer`. diff --git a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx index a992e2599..a77c50f9e 100644 --- a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx +++ b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx @@ -1,8 +1,8 @@ import React, { useCallback } from 'react'; +import BlockOutlinedIcon from '@mui/icons-material/BlockOutlined'; import { alpha, Box, - Button, ButtonBase, FormControlLabel, Radio, @@ -10,11 +10,13 @@ import { 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; @@ -38,19 +40,40 @@ function EndpointLabels({ options }: { options: LikertOption[] }) { sx={{ display: 'flex', justifyContent: 'space-between', + gap: 1, px: 0.5, mt: 0.5, }}> - + {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, @@ -59,6 +82,7 @@ export default function LikertScaleControl({ hasError, }: LikertScaleControlProps) { const theme = useTheme(); + const isPhone = useMediaQuery(theme.breakpoints.down('sm')); const { options, display, @@ -68,12 +92,13 @@ export default function LikertScaleControl({ allowNotApplicable, notApplicableLabel, notApplicableValue, - orientation, + layout, } = resolved; const isNa = isNotApplicableValue(value, notApplicableValue); const scaleValue = isNa ? null : value; - const vertical = orientation === 'vertical'; + const vertical = layout.mode === 'vertical'; + const useGrid = layout.mode === 'columns'; const handleSelect = useCallback( (newValue: unknown) => { @@ -107,12 +132,20 @@ export default function LikertScaleControl({ ? theme.palette.error.main : theme.palette.divider; + type CellWidth = 'word' | 'emoji' | 'compact'; + /** - * One shared cell style for buttons / numeric / emoji so every Likert - * looks the same: outlined neutral cells, tinted accent when selected. + * 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) => { + 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, @@ -120,7 +153,7 @@ export default function LikertScaleControl({ borderRadius: 1.5, border: '1px solid', borderColor: selected ? accent : neutralBorder, - backgroundColor: selected ? alpha(accent, 0.1) : 'transparent', + backgroundColor: selected ? alpha(accent, 0.12) : unselectedBg, color: selected ? accent : theme.palette.text.primary, fontWeight: selected ? 600 : 400, fontSize: '0.8125rem', @@ -129,46 +162,126 @@ export default function LikertScaleControl({ overflowWrap: 'break-word' as const, hyphens: 'auto' as const, transition: theme.transitions.create( - ['border-color', 'background-color', 'color'], + ['border-color', 'background-color', 'color', 'opacity'], { duration: theme.transitions.duration.shortest }, ), - '&:hover': { - backgroundColor: selected - ? alpha(accent, 0.14) - : theme.palette.action.hover, - }, + '&: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': { - color: theme.palette.text.disabled, - borderColor: theme.palette.divider, + '&.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, }, }; }; - const cellRowSx = { - display: 'flex', - flexDirection: vertical ? 'column' : 'row', - flexWrap: vertical ? 'nowrap' : 'wrap', - gap: 0.75, - } as const; + const cellRowSx = useGrid + ? choiceListSx(layout) + : { + display: 'flex', + flexDirection: vertical ? 'column' : 'row', + flexWrap: vertical ? 'nowrap' : 'wrap', + gap: 0.75, + }; /** - * Horizontal sizing per variant: - * - labeled cells share the row equally but wrap onto extra rows when a - * cell would drop below a readable width (long labels, narrow screens); - * - compact cells (numeric) keep a fixed square-ish footprint and wrap. + * Horizontal sizing per variant, tuned for tablets AND phones: + * - `word` : full-width stacked rows on phones (xs), equal columns from + * the sm breakpoint up — the standard mobile survey pattern + * that keeps long labels readable without cramped wrapping; + * - `compact`: small fixed cells (numeric/NPS) that wrap when they run out + * of room, staying touch-friendly (44px min) on any screen. */ - const horizontalCellSx = (labelled: boolean) => - labelled - ? { flex: '1 1 88px', minWidth: 72 } - : { flex: '0 0 auto', minWidth: 48 }; + const horizontalCellSx = (width: CellWidth) => { + switch (width) { + case 'word': + return useGrid + ? { width: '100%' } + : { + flex: { xs: '1 1 100%', sm: '1 1 96px' }, + minWidth: { sm: 72 }, + }; + case 'emoji': + // Even grid that wraps predictably on any width. + return { flex: '1 1 72px', minWidth: 64 }; + case 'compact': + default: + return { flex: '0 0 auto', minWidth: 48 }; + } + }; + + const renderNaCell = (inline: boolean) => { + if (!allowNotApplicable) return null; + return ( + + + {notApplicableLabel} + + ); + }; + + const inlineNa = + allowNotApplicable && + !vertical && + (display === 'buttons' || display === 'numeric' || display === 'emoji'); const renderCells = ( content: (opt: LikertOption, index: number) => React.ReactNode, - labelled: boolean, + width: CellWidth, + showEndpoints: boolean, ) => ( @@ -183,27 +296,35 @@ export default function LikertScaleControl({ aria-pressed={selected} focusRipple sx={{ - ...cellSx(index, selected), + ...cellSx(index, selected, width), ...(vertical ? { justifyContent: 'flex-start', textAlign: 'left', px: 1.5 } - : horizontalCellSx(labelled)), + : horizontalCellSx(width)), }}> {content(opt, index)} ); })} + {inlineNa && renderNaCell(true)} - {endpointLabelsOnly && !vertical && } + {showEndpoints && !vertical && } ); const renderButtons = () => renderCells( opt => (endpointLabelsOnly ? String(opt.value) : opt.label), - !endpointLabelsOnly, + endpointLabelsOnly ? 'compact' : 'word', + endpointLabelsOnly, ); - const renderNumeric = () => renderCells(opt => String(opt.value), false); + const renderNumeric = () => + renderCells( + opt => String(opt.value), + 'compact', + // Surface verbal endpoint anchors when the form provides them. + hasWordEndpoints(options), + ); const renderEmoji = () => renderCells( @@ -220,68 +341,85 @@ export default function LikertScaleControl({ sx={{ fontSize: '1.5rem', lineHeight: 1 }}> {opt.emoji ?? opt.label.charAt(0)} - {!endpointLabelsOnly && ( - - {opt.label} - - )} + {/* Always pair the emoji with its text label to avoid the + cultural/interpretive ambiguity of emoji-only scales. */} + + {opt.label} + ), - true, + 'emoji', + false, ); - const renderRadio = () => ( - - { - const opt = options.find(o => String(o.value) === e.target.value); - if (opt) handleSelect(opt.value); - }}> - { + // 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={vertical ? 'end' : 'bottom'} - label={ - - {endpointLabelsOnly ? String(opt.value) : opt.label} - - } - sx={ - vertical - ? undefined - : { - flex: endpointLabelsOnly ? '0 1 48px' : '1 1 64px', - m: 0, - minWidth: endpointLabelsOnly ? 40 : 56, - alignItems: 'center', - } - } - /> - ))} - - - {endpointLabelsOnly && !vertical && } - - ); + + {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); @@ -306,7 +444,8 @@ export default function LikertScaleControl({ marks value={Number.isNaN(current) ? min : current} onChange={(_e, v) => handleSelect(v as number)} - valueLabelDisplay="auto" + valueLabelDisplay="on" + valueLabelFormat={v => `${v}/${max}`} sx={ colorMode === 'spectrum' && currentIndex >= 0 ? { color: accentFor(currentIndex) } @@ -376,26 +515,7 @@ export default function LikertScaleControl({ return ( {renderScale()} - {allowNotApplicable && ( - - )} + {allowNotApplicable && !inlineNa && renderNaCell(false)} ); } diff --git a/formulus-formplayer/src/components/likert/likertConfig.ts b/formulus-formplayer/src/components/likert/likertConfig.ts index e66791d6e..c2534ad8e 100644 --- a/formulus-formplayer/src/components/likert/likertConfig.ts +++ b/formulus-formplayer/src/components/likert/likertConfig.ts @@ -1,32 +1,23 @@ 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 = { - const?: unknown; - enum?: unknown[]; - title?: string; - emoji?: string; -}; +type OneOfEntry = LikertOneOfEntry; function oneOfToOptions(oneOf: OneOfEntry[]): LikertOption[] { - return oneOf.map(entry => { - const value = - entry.const !== undefined - ? (entry.const as string | number) - : (entry.enum?.[0] as string | number); - return { - value, - label: entry.title ?? String(value), - emoji: entry.emoji, - }; - }); + return oneOf.map(entry => ({ + value: entry.const, + label: entry.title ?? String(entry.const), + emoji: entry.emoji, + })); } export function parseLikertConfig(schema: JsonSchema): LikertConfig { @@ -54,8 +45,10 @@ export function resolveLikertOptions( const display = uiDisplay ?? config.display ?? (options.length > 7 ? 'slider' : 'buttons'); - const orientation = - uiOptions.orientation === 'vertical' ? 'vertical' : 'horizontal'; + const layout = + uiOptions.orientation != null + ? parseChoiceLayout(uiOptions) + : { mode: 'horizontal' as const }; const colorMode = resolveEffectiveColorMode(display, config.colorMode); @@ -72,7 +65,7 @@ export function resolveLikertOptions( config.notApplicableValue !== undefined ? config.notApplicableValue : null, - orientation, + layout, }; } 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 index 17fa4228b..4009f8d9a 100644 --- a/formulus-formplayer/src/components/likert/likertTypes.ts +++ b/formulus-formplayer/src/components/likert/likertTypes.ts @@ -1,3 +1,6 @@ +import type { JsonSchema7 } from '@jsonforms/core'; +import type { ChoiceLayout } from '../../theme/choiceLayout'; + export type LikertPreset = | 'agreement' | 'frequency' @@ -18,6 +21,13 @@ export type LikertDisplay = 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; @@ -44,10 +54,19 @@ export interface ResolvedLikertOptions { allowNotApplicable: boolean; notApplicableLabel: string; notApplicableValue: null | string | number; - orientation: 'horizontal' | 'vertical'; + layout: ChoiceLayout; } -/** JSON Schema field with optional Likert extension (formplayer built-in). */ -export type LikertJsonSchema = import('@jsonforms/core').JsonSchema7 & { +/** 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/LikertScaleQuestionRenderer.test.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx index 219cd9c93..3855f73f7 100644 --- a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx @@ -1,10 +1,10 @@ // @vitest-environment jsdom import React, { useState } from 'react'; import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, screen, cleanup, fireEvent } from '@testing-library/react'; +import { render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react'; import { ThemeProvider } from '@mui/material/styles'; import { JsonForms } from '@jsonforms/react'; -import type { JsonSchema7, UISchemaElement } from '@jsonforms/core'; +import type { UISchemaElement } from '@jsonforms/core'; import { materialRenderers } from '@jsonforms/material-renderers'; import Ajv from 'ajv'; import { theme } from '../theme/theme'; @@ -14,7 +14,10 @@ import LikertScaleQuestionRenderer, { } from './LikertScaleQuestionRenderer'; import LikertScaleControl from '../components/likert/LikertScaleControl'; import { resolveLikertOptions } from '../components/likert/likertConfig'; -import type { LikertJsonSchema } from '../components/likert/likertTypes'; +import type { + LikertJsonSchema, + LikertObjectJsonSchema, +} from '../components/likert/likertTypes'; import { shellMaterialRenderers } from '../theme/material-wrappers'; const ajv = new Ajv({ allErrors: true, strict: false }); @@ -38,7 +41,7 @@ const likertFieldSchema: LikertJsonSchema = { }, }; -const satisfactionSchema: JsonSchema7 = { +const satisfactionSchema: LikertObjectJsonSchema = { type: 'object', properties: { satisfaction: likertFieldSchema, @@ -58,8 +61,10 @@ const productionRenderers = [ function LikertIntegrationHarness({ initialData = {}, + uischema = satisfactionUischema, }: { initialData?: Record; + uischema?: UISchemaElement; }) { const [data, setData] = useState>(initialData); return ( @@ -72,17 +77,22 @@ function LikertIntegrationHarness({ }}> setData(d || {})} /> +
{JSON.stringify(data)}
); } +function readCommittedData(): Record { + return JSON.parse(screen.getByTestId('committed-data').textContent || '{}'); +} + afterEach(() => cleanup()); describe('likertScaleQuestionTester', () => { @@ -163,10 +173,26 @@ describe('LikertScaleQuestionRenderer integration', () => { 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', async () => { + it('uses preset when oneOf is omitted', () => { const resolved = resolveLikertOptions({ type: 'integer', format: 'likert', @@ -175,4 +201,75 @@ describe('resolveLikertOptions', () => { 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 }); + }); +}); + +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/stories/LikertScaleQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx index 0d3f0a1da..ae92b72cd 100644 --- a/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx +++ b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx @@ -5,8 +5,14 @@ import LikertScaleQuestionRenderer, { likertScaleQuestionTester, } from '../renderers/LikertScaleQuestionRenderer'; import { materialRenderers } from '@jsonforms/material-renderers'; +import type { LikertOneOfEntry } from '../components/likert/likertTypes'; +import { + likertField, + likertObjectSchema, + likertPresetField, +} from '../components/likert/likertSchemaHelpers'; -const satisfactionOneOf = [ +const satisfactionOneOf: LikertOneOfEntry[] = [ { const: 1, title: 'Very dissatisfied' }, { const: 2, title: 'Dissatisfied' }, { const: 3, title: 'Neutral' }, @@ -14,7 +20,7 @@ const satisfactionOneOf = [ { const: 5, title: 'Very satisfied' }, ]; -const npsOneOf = Array.from({ length: 11 }, (_, i) => ({ +const npsOneOf: LikertOneOfEntry[] = Array.from({ length: 11 }, (_, i) => ({ const: i, title: i === 0 ? 'Not at all likely' : i === 10 ? 'Extremely likely' : String(i), @@ -42,23 +48,17 @@ const meta: Meta = { export default meta; type Story = StoryObj; -function likertSchema( - overrides: Record = {}, - likert: Record = {}, +function satisfactionSchema( + likert: Parameters[1]['likert'] = {}, + fieldOverrides: Parameters[1] = {}, ) { - return { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', - title: 'How satisfied are you with the service?', - oneOf: satisfactionOneOf, - likert: { display: 'buttons', ...likert }, - ...overrides, - }, - }, - }; + return likertObjectSchema( + likertField(satisfactionOneOf, { + title: 'How satisfied are you with the service?', + likert: { display: 'buttons', ...likert }, + ...fieldOverrides, + }), + ); } const uischema = { @@ -69,17 +69,12 @@ const uischema = { // --- Display variants ------------------------------------------------------- export const Buttons: Story = { - args: { - schema: likertSchema(), - uischema, - initialData: {}, - renderers, - }, + args: { schema: satisfactionSchema(), uischema, initialData: {}, renderers }, }; export const ButtonsSelected: Story = { args: { - schema: likertSchema(), + schema: satisfactionSchema(), uischema, initialData: { satisfaction: 4 }, renderers, @@ -88,7 +83,7 @@ export const ButtonsSelected: Story = { export const ButtonsSpectrum: Story = { args: { - schema: likertSchema({}, { colorMode: 'spectrum' }), + schema: satisfactionSchema({ colorMode: 'spectrum' }), uischema, initialData: { satisfaction: 1 }, renderers, @@ -97,7 +92,7 @@ export const ButtonsSpectrum: Story = { export const RadioRow: Story = { args: { - schema: likertSchema({}, { display: 'radio' }), + schema: satisfactionSchema({ display: 'radio' }), uischema, initialData: { satisfaction: 3 }, renderers, @@ -106,18 +101,12 @@ export const RadioRow: Story = { export const Slider: Story = { args: { - schema: { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', - title: 'Rate your experience (0–10)', - oneOf: npsOneOf, - likert: { display: 'slider' }, - }, - }, - }, + schema: likertObjectSchema( + likertField(npsOneOf, { + title: 'Rate your experience (0–10)', + likert: { display: 'slider' }, + }), + ), uischema, initialData: { satisfaction: 7 }, renderers, @@ -126,30 +115,57 @@ export const Slider: Story = { export const NumericScale: Story = { args: { - schema: { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', + 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?', - oneOf: Array.from({ length: 5 }, (_, i) => ({ - const: i + 1, - title: String(i + 1), - })), 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: likertSchema({}, { display: 'stars' }), + schema: satisfactionSchema({ display: 'stars' }), uischema, initialData: { satisfaction: 4 }, renderers, @@ -158,24 +174,21 @@ export const Stars: Story = { export const Emoji: Story = { args: { - schema: { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', + 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?', - oneOf: [ - { 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: '😄' }, - ], likert: { display: 'emoji' }, }, - }, - }, + ), + ), uischema, initialData: { satisfaction: 5 }, renderers, @@ -187,18 +200,12 @@ export const Emoji: Story = { export const NpsEndpointLabelsOnly: Story = { name: 'NPS 0–10 (endpoint labels only)', args: { - schema: { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', - title: 'How likely are you to recommend us?', - oneOf: npsOneOf, - likert: { display: 'buttons', endpointLabelsOnly: true }, - }, - }, - }, + schema: likertObjectSchema( + likertField(npsOneOf, { + title: 'How likely are you to recommend us?', + likert: { display: 'buttons', endpointLabelsOnly: true }, + }), + ), uischema, initialData: { satisfaction: 8 }, renderers, @@ -207,21 +214,14 @@ export const NpsEndpointLabelsOnly: Story = { export const PresetAgreement: Story = { args: { - schema: { - type: 'object', - properties: { - agreement: { - type: 'integer', - format: 'likert', - title: 'I would recommend this service', - likert: { preset: 'agreement', display: 'buttons' }, - }, - }, - }, - uischema: { - type: 'Control', - scope: '#/properties/agreement', - }, + schema: likertObjectSchema( + likertPresetField('agreement', { + title: 'I would recommend this service', + likert: { display: 'buttons' }, + }), + 'agreement', + ), + uischema: { type: 'Control', scope: '#/properties/agreement' }, initialData: {}, renderers, }, @@ -229,12 +229,12 @@ export const PresetAgreement: Story = { export const WithNotApplicable: Story = { args: { - schema: likertSchema( - { type: ['integer', 'null'] }, + schema: satisfactionSchema( { allowNotApplicable: true, notApplicableLabel: 'Not applicable', }, + { type: ['integer', 'null'] }, ), uischema, initialData: { satisfaction: null }, @@ -244,24 +244,35 @@ export const WithNotApplicable: Story = { export const StackedVertical: Story = { args: { - schema: likertSchema(), - uischema: { - ...uischema, - options: { orientation: 'vertical' }, - }, + 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: { - ...likertSchema(), - required: ['satisfaction'], - }, + schema: likertObjectSchema( + likertField(satisfactionOneOf, { + title: 'How satisfied are you with the service?', + likert: { display: 'buttons' }, + }), + 'satisfaction', + ['satisfaction'], + ), uischema, initialData: {}, renderers, @@ -271,36 +282,98 @@ export const RequiredError: Story = { export const Disabled: Story = { args: { - schema: likertSchema(), + 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: { - ...uischema, + type: 'Control', + scope: '#/properties/satisfaction', options: { readonly: true }, }, - initialData: { satisfaction: 3 }, + 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: { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', + 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?', - oneOf: [ - { const: 1, title: 'Très insatisfait' }, - { const: 2, title: 'Insatisfait' }, - { const: 3, title: 'Neutre' }, - { const: 4, title: 'Satisfait' }, - { const: 5, title: 'Très satisfait' }, - ], likert: { display: 'buttons' }, }, - }, - }, + ), + ), uischema, initialData: {}, renderers, @@ -313,7 +386,7 @@ export const MobileButtons: Story = { name: 'Mobile width: buttons wrap', decorators: [mobileDecorator], args: { - schema: likertSchema(), + schema: satisfactionSchema(), uischema, initialData: { satisfaction: 4 }, renderers, @@ -324,18 +397,12 @@ export const MobileNps: Story = { name: 'Mobile width: NPS 0–10', decorators: [mobileDecorator], args: { - schema: { - type: 'object', - properties: { - satisfaction: { - type: 'integer', - format: 'likert', - title: 'How likely are you to recommend us?', - oneOf: npsOneOf, - likert: { display: 'buttons', endpointLabelsOnly: true }, - }, - }, - }, + schema: likertObjectSchema( + likertField(npsOneOf, { + title: 'How likely are you to recommend us?', + likert: { display: 'buttons', endpointLabelsOnly: true }, + }), + ), uischema, initialData: { satisfaction: 6 }, renderers, @@ -346,7 +413,7 @@ export const MobileRadioRow: Story = { name: 'Mobile width: radio row', decorators: [mobileDecorator], args: { - schema: likertSchema({}, { display: 'radio' }), + schema: satisfactionSchema({ display: 'radio' }), uischema, initialData: {}, renderers, From b40a47e04cd3eaa78e844b8a0981c9dbb0af84c7 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 2 Jul 2026 11:59:49 +0300 Subject: [PATCH 3/6] fix(formplayer): type Likert story helper with LikertConfig Avoid indexing an optional parameter type (TS2339) by annotating the story helper's likert arg as LikertConfig directly. --- .../src/stories/LikertScaleQuestionRenderer.stories.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx index ae92b72cd..6adde0e18 100644 --- a/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx +++ b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx @@ -5,7 +5,10 @@ import LikertScaleQuestionRenderer, { likertScaleQuestionTester, } from '../renderers/LikertScaleQuestionRenderer'; import { materialRenderers } from '@jsonforms/material-renderers'; -import type { LikertOneOfEntry } from '../components/likert/likertTypes'; +import type { + LikertConfig, + LikertOneOfEntry, +} from '../components/likert/likertTypes'; import { likertField, likertObjectSchema, @@ -49,8 +52,8 @@ export default meta; type Story = StoryObj; function satisfactionSchema( - likert: Parameters[1]['likert'] = {}, - fieldOverrides: Parameters[1] = {}, + likert: LikertConfig = {}, + fieldOverrides: Omit[1]>, 'likert'> = {}, ) { return likertObjectSchema( likertField(satisfactionOneOf, { From e12c9e67ff524d7a1e1e1fefb14d18f2064fa717 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 2 Jul 2026 12:16:21 +0300 Subject: [PATCH 4/6] docs(formplayer): trim Likert/duration README to a docs-site pointer Move form-author configuration reference for the likert/duration question types to the docs site; keep only dev-oriented file/Storybook locations in the formplayer README. --- formulus-formplayer/README.md | 93 ++--------------------------------- 1 file changed, 4 insertions(+), 89 deletions(-) diff --git a/formulus-formplayer/README.md b/formulus-formplayer/README.md index 59ff56cac..11b3b38e2 100644 --- a/formulus-formplayer/README.md +++ b/formulus-formplayer/README.md @@ -211,97 +211,12 @@ Rebuild formplayer (`pnpm run build:copy`) before testing in Formulus or Desktop ## Built-in question types: Likert scale and duration -These are **built-in** renderers (same pattern as `photo`, `gps`, `signature`) — not app-bundle `question_types/`. - -### Likert scale (`format: "likert"`) - -Use for agreement, satisfaction, frequency, and numeric rating scales. Stored value is the selected `oneOf[].const` (typically an integer). - -```json -{ - "type": "integer", - "format": "likert", - "title": "How satisfied are you with the service?", - "oneOf": [ - { "const": 1, "title": "Very dissatisfied" }, - { "const": 5, "title": "Very satisfied" } - ], - "likert": { - "preset": "satisfaction", - "display": "buttons", - "colorMode": "spectrum", - "allowClear": true, - "allowNotApplicable": false - } -} -``` - -All variants share one standard look: outlined neutral cells; the selected option gets an accent border + tint. Unselected options are never colored. - -| `likert.display` | UI | -| ---------------- | -------------------------------------------------- | -| `buttons` | Equal-width outlined option cells (default) | -| `radio` | Radio row with labels below (classic survey style) | -| `slider` | MUI slider with tick marks and endpoint labels | -| `numeric` | Compact number cells (NPS style) | -| `stars` | MUI `Rating` stars with selected label | -| `emoji` | Emoji per option (`emoji` on each `oneOf` entry) | - -| `likert.colorMode` | Selected-option accent | -| ------------------ | -------------------------------------------------- | -| `neutral` | Theme primary (default) | -| `spectrum` | Semantic red / yellow / green by scale position | -| `stars` | Standard MUI Rating gold (when `display: "stars"`) | - -Presets (when `oneOf` is omitted): `agreement`, `frequency`, `satisfaction`, `importance`, `likelihood`, `numeric_0_10`, `numeric_1_5`, `numeric_1_7`. - -**Recommended for rating scales:** use `display: "numeric"` and give the first/last `oneOf` entries verbal `title`s (e.g. `0` → "No pain", `10` → "Worst pain"). The control renders the numbers in cells with the words as endpoint anchors below — the highest-reliability, most practitioner-preferred pattern. For opinion questions, word `buttons` are fastest to answer. Emoji always render with their text label to avoid interpretive ambiguity. - -**Responsive layout (tablet + phone):** word/button and radio scales lay out as a row on tablets/desktop and automatically stack to one option per row on phones for readability; numeric/emoji cells wrap in an even grid. Set `ui.json` `options.orientation` to `vertical` (stacked), `horizontal`, `flow` (wrap), or `cols-2` … `cols-5` (two-column grid on tablets). Every option is a ≥44px touch target. `options.display` overrides `likert.display`. - -**Label guidance (form authors):** - -| Display | When to use | Label pattern | -| -------------------------------------- | ------------------------------- | ---------------------------------------------------------------- | -| `buttons` / `radio` | Opinion scales (3–5 options) | Full label per option (`oneOf[].title`) | -| `numeric` | NPS, pain, rating (5+ points) | Numbers in cells; word labels on first/last `oneOf` entries only | -| `buttons` + `endpointLabelsOnly: true` | NPS 0–10 in button form | Digits in cells; endpoint words below | -| `slider` | Continuous 0–10 ranges | Endpoint word anchors below; value badge always visible (`7/10`) | -| `emoji` | Optional sentiment (low-stakes) | Emoji + text label on every option (never emoji-only) | -| `stars` | 5-point satisfaction | Star count + selected label beside control | - -Set `likert.endpointLabelsOnly: true` explicitly for long numeric scales. For 3–4 option scales, omit it so every option shows its full label. - -**Not applicable:** set `likert.allowNotApplicable: true` and use `type: ["integer", "null"]`. The N/A option appears inline at the end of button/numeric/emoji rows (dashed pill with ⊘ icon), or below for radio/slider/stars. - -Storybook: `Question Renderers/LikertScaleQuestionRenderer`. - -### Duration / timer (`format: "duration"`) - -Captures elapsed time as **seconds** (JSON number). Commit on explicit **Save** (stopwatch) or manual entry blur. - -```json -{ - "type": "number", - "format": "duration", - "title": "Time to complete the task", - "duration": { - "mode": "stopwatch", - "unit": "seconds", - "precision": 1, - "allowManualEntry": true, - "countdownFrom": null - } -} -``` +`format: "likert"` and `format: "duration"` are **built-in** renderers (same pattern as `photo`, `gps`, `signature`) — not app-bundle `question_types/`. -| `duration.mode` | UI | -| --------------- | -------------------------------------- | -| `stopwatch` | Start / Pause / Resume / Reset / Save | -| `countdown` | Countdown from `countdownFrom` seconds | -| `manual` | Numeric seconds field only | +- Likert: `src/renderers/LikertScaleQuestionRenderer.tsx` + `src/components/likert/`. Storybook: `Question Renderers/LikertScaleQuestionRenderer`. +- Duration: `src/renderers/DurationQuestionRenderer.tsx` + `src/components/duration/`. Storybook: `Question Renderers/DurationQuestionRenderer`. -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 From 7bc42f8cb15128014d63d555c80c28c4c14a3ad3 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 2 Jul 2026 17:10:33 +0300 Subject: [PATCH 5/6] fix(formplayer): equal-width Likert layout and N/A validation Use CSS grid for word and emoji scales so every option stays the same width on tablets, normalize schemas for Not applicable validation, and wire ui.json option translations into Likert labels. --- formulus-formplayer/src/App.tsx | 5 +- .../components/likert/LikertScaleControl.tsx | 59 +++++---- .../src/components/likert/likertConfig.ts | 115 +++++++++++++++++- .../LikertScaleQuestionRenderer.test.tsx | 107 +++++++++++++++- 4 files changed, 257 insertions(+), 29 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 08c2b85d7..69e1815aa 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -101,6 +101,7 @@ import { numberStepperRenderer } from './renderers/NumberStepperRenderer'; import LikertScaleQuestionRenderer, { likertScaleQuestionTester, } from './renderers/LikertScaleQuestionRenderer'; +import { injectLikertNotApplicable } from './components/likert/likertConfig'; import DurationQuestionRenderer, { durationQuestionTester, } from './renderers/DurationQuestionRenderer'; @@ -672,7 +673,9 @@ function App() { ); setUISchema(withLocale); } else { - setSchema(formSchema as FormSchema); + setSchema( + injectLikertNotApplicable(formSchema) as FormSchema, + ); const swipeLayoutUISchema = ensureSwipeLayoutRoot( uiSchema as FormUISchema, ); diff --git a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx index a77c50f9e..46b591e6c 100644 --- a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx +++ b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx @@ -217,35 +217,46 @@ export default function LikertScaleControl({ }; }; - const cellRowSx = useGrid - ? choiceListSx(layout) - : { - display: 'flex', - flexDirection: vertical ? 'column' : 'row', - flexWrap: vertical ? 'nowrap' : 'wrap', + /** + * 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 sizing per variant, tuned for tablets AND phones: - * - `word` : full-width stacked rows on phones (xs), equal columns from - * the sm breakpoint up — the standard mobile survey pattern - * that keeps long labels readable without cramped wrapping; - * - `compact`: small fixed cells (numeric/NPS) that wrap when they run out - * of room, staying touch-friendly (44px min) on any screen. + * 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': - return useGrid - ? { width: '100%' } - : { - flex: { xs: '1 1 100%', sm: '1 1 96px' }, - minWidth: { sm: 72 }, - }; case 'emoji': - // Even grid that wraps predictably on any width. - return { flex: '1 1 72px', minWidth: 64 }; + return useGrid ? { width: '100%' } : { minWidth: 0 }; case 'compact': default: return { flex: '0 0 auto', minWidth: 48 }; @@ -273,10 +284,14 @@ export default function LikertScaleControl({ ); }; + // 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 === 'buttons' || display === 'numeric' || display === 'emoji'); + (display === 'numeric' || + (display === 'buttons' && endpointLabelsOnly)); const renderCells = ( content: (opt: LikertOption, index: number) => React.ReactNode, @@ -284,7 +299,7 @@ export default function LikertScaleControl({ showEndpoints: boolean, ) => ( - + {options.map((opt, index) => { const selected = valuesEqual(scaleValue, opt.value); return ( diff --git a/formulus-formplayer/src/components/likert/likertConfig.ts b/formulus-formplayer/src/components/likert/likertConfig.ts index c2534ad8e..cfb06c42c 100644 --- a/formulus-formplayer/src/components/likert/likertConfig.ts +++ b/formulus-formplayer/src/components/likert/likertConfig.ts @@ -41,6 +41,42 @@ export function resolveLikertOptions( 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'); @@ -59,12 +95,9 @@ export function resolveLikertOptions( endpointLabelsOnly: config.endpointLabelsOnly ?? uiOptions.endpointLabelsOnly === true, allowClear: config.allowClear !== false, - allowNotApplicable: config.allowNotApplicable === true, + allowNotApplicable, notApplicableLabel: config.notApplicableLabel ?? 'Not applicable', - notApplicableValue: - config.notApplicableValue !== undefined - ? config.notApplicableValue - : null, + notApplicableValue, layout, }; } @@ -92,3 +125,75 @@ export function isNotApplicableValue( 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/renderers/LikertScaleQuestionRenderer.test.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx index 3855f73f7..e91754589 100644 --- a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx @@ -13,7 +13,10 @@ import LikertScaleQuestionRenderer, { likertScaleQuestionTester, } from './LikertScaleQuestionRenderer'; import LikertScaleControl from '../components/likert/LikertScaleControl'; -import { resolveLikertOptions } from '../components/likert/likertConfig'; +import { + resolveLikertOptions, + injectLikertNotApplicable, +} from '../components/likert/likertConfig'; import type { LikertJsonSchema, LikertObjectJsonSchema, @@ -213,6 +216,108 @@ describe('resolveLikertOptions', () => { }); 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', () => { From 06ebe9783194e452fc4ce5683549f595a0ff7633 Mon Sep 17 00:00:00 2001 From: Najuna Brian Date: Thu, 2 Jul 2026 17:12:28 +0300 Subject: [PATCH 6/6] style(formplayer): fix Prettier formatting for Likert files Run Prettier on Likert tests, stories, and related source so formplayer format:check passes in CI. --- formulus-formplayer/src/App.tsx | 4 +--- .../src/components/likert/LikertScaleControl.tsx | 3 +-- formulus-formplayer/src/components/likert/likertConfig.ts | 4 +++- .../src/renderers/LikertScaleQuestionRenderer.test.tsx | 8 +++++++- .../src/stories/LikertScaleQuestionRenderer.stories.tsx | 5 ++++- 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 69e1815aa..14eaa1b66 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -673,9 +673,7 @@ function App() { ); setUISchema(withLocale); } else { - setSchema( - injectLikertNotApplicable(formSchema) as FormSchema, - ); + setSchema(injectLikertNotApplicable(formSchema) as FormSchema); const swipeLayoutUISchema = ensureSwipeLayoutRoot( uiSchema as FormUISchema, ); diff --git a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx index 46b591e6c..d9cfb07ed 100644 --- a/formulus-formplayer/src/components/likert/LikertScaleControl.tsx +++ b/formulus-formplayer/src/components/likert/LikertScaleControl.tsx @@ -290,8 +290,7 @@ export default function LikertScaleControl({ const inlineNa = allowNotApplicable && !vertical && - (display === 'numeric' || - (display === 'buttons' && endpointLabelsOnly)); + (display === 'numeric' || (display === 'buttons' && endpointLabelsOnly)); const renderCells = ( content: (opt: LikertOption, index: number) => React.ReactNode, diff --git a/formulus-formplayer/src/components/likert/likertConfig.ts b/formulus-formplayer/src/components/likert/likertConfig.ts index cfb06c42c..86f4bfd58 100644 --- a/formulus-formplayer/src/components/likert/likertConfig.ts +++ b/formulus-formplayer/src/components/likert/likertConfig.ts @@ -158,7 +158,9 @@ function normalizeLikertNode(node: MutableSchemaNode): void { if (naValue === null) ensureTypeAllowsNull(node); if (Array.isArray(node.oneOf)) { - const exists = node.oneOf.some(entry => valuesEqual(entry?.const, naValue)); + const exists = node.oneOf.some(entry => + valuesEqual(entry?.const, naValue), + ); if (!exists) { node.oneOf = [ ...node.oneOf, diff --git a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx index e91754589..e966dd151 100644 --- a/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx +++ b/formulus-formplayer/src/renderers/LikertScaleQuestionRenderer.test.tsx @@ -1,7 +1,13 @@ // @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 { + 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'; diff --git a/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx index 6adde0e18..8c25be774 100644 --- a/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx +++ b/formulus-formplayer/src/stories/LikertScaleQuestionRenderer.stories.tsx @@ -53,7 +53,10 @@ type Story = StoryObj; function satisfactionSchema( likert: LikertConfig = {}, - fieldOverrides: Omit[1]>, 'likert'> = {}, + fieldOverrides: Omit< + NonNullable[1]>, + 'likert' + > = {}, ) { return likertObjectSchema( likertField(satisfactionOneOf, {