From 77cf19dbd315b8e88793ce9ae6e5dae47bf62738 Mon Sep 17 00:00:00 2001 From: Emil Rossing Date: Mon, 22 Jun 2026 18:57:43 +0200 Subject: [PATCH] fix(formplayer): Add support for more layout options, and make ODE Desktop fallback to look in repo root for custom q types --- desktop/src-tauri/src/lib.rs | 15 ++++ .../src/theme/ChoiceOptionList.tsx | 25 ++++++ .../src/theme/choiceLayout.test.ts | 82 ++++++++++++++++++ formulus-formplayer/src/theme/choiceLayout.ts | 84 +++++++++++++++++++ .../src/theme/material-wrappers.tsx | 62 +++++--------- 5 files changed, 227 insertions(+), 41 deletions(-) create mode 100644 formulus-formplayer/src/theme/ChoiceOptionList.tsx create mode 100644 formulus-formplayer/src/theme/choiceLayout.test.ts create mode 100644 formulus-formplayer/src/theme/choiceLayout.ts diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index e31a5d2b2..c0abc3a63 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1021,6 +1021,21 @@ fn mirror_custom_app_dev_folder(ws: &Path, source: &Path) -> Result{children}; + } + return ( + + {children} + + ); +} diff --git a/formulus-formplayer/src/theme/choiceLayout.test.ts b/formulus-formplayer/src/theme/choiceLayout.test.ts new file mode 100644 index 000000000..039b1df1b --- /dev/null +++ b/formulus-formplayer/src/theme/choiceLayout.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest'; +import { + parseChoiceLayout, + choiceListSx, + toggleButtonListSx, + toggleButtonOrientation, +} from './choiceLayout'; + +describe('parseChoiceLayout', () => { + it('defaults to vertical when options are missing', () => { + expect(parseChoiceLayout()).toEqual({ mode: 'vertical' }); + expect(parseChoiceLayout({})).toEqual({ mode: 'vertical' }); + }); + + it('parses horizontal and flow', () => { + expect(parseChoiceLayout({ orientation: 'horizontal' })).toEqual({ + mode: 'horizontal', + }); + expect(parseChoiceLayout({ orientation: 'flow' })).toEqual({ + mode: 'flow', + }); + }); + + it('parses cols-2 through cols-5', () => { + expect(parseChoiceLayout({ orientation: 'cols-2' })).toEqual({ + mode: 'columns', + columns: 2, + }); + expect(parseChoiceLayout({ orientation: 'cols-3' })).toEqual({ + mode: 'columns', + columns: 3, + }); + expect(parseChoiceLayout({ orientation: 'cols-4' })).toEqual({ + mode: 'columns', + columns: 4, + }); + expect(parseChoiceLayout({ orientation: 'cols-5' })).toEqual({ + mode: 'columns', + columns: 5, + }); + }); + + it('falls back to vertical for unknown orientation values', () => { + expect(parseChoiceLayout({ orientation: 'cols-1' })).toEqual({ + mode: 'vertical', + }); + expect(parseChoiceLayout({ orientation: 'cols-6' })).toEqual({ + mode: 'vertical', + }); + expect(parseChoiceLayout({ orientation: 'grid' })).toEqual({ + mode: 'vertical', + }); + }); +}); + +describe('choiceListSx', () => { + it('uses flex row wrap for flow', () => { + const sx = choiceListSx({ mode: 'flow' }) as Record; + expect(sx.display).toBe('flex'); + expect(sx.flexDirection).toBe('row'); + expect(sx.flexWrap).toBe('wrap'); + }); + + it('uses CSS grid for column layouts', () => { + const sx = choiceListSx({ mode: 'columns', columns: 3 }) as Record< + string, + unknown + >; + expect(sx.display).toBe('grid'); + expect(sx.gridTemplateColumns).toBe('repeat(3, minmax(0, 1fr))'); + expect(sx.alignItems).toBe('start'); + }); +}); + +describe('toggle button layout helpers', () => { + it('maps column mode to horizontal orientation with wrap', () => { + const layout = { mode: 'columns' as const, columns: 3 as const }; + expect(toggleButtonOrientation(layout)).toBe('horizontal'); + const sx = toggleButtonListSx(layout, false) as Record; + expect(sx.flexWrap).toBe('wrap'); + }); +}); diff --git a/formulus-formplayer/src/theme/choiceLayout.ts b/formulus-formplayer/src/theme/choiceLayout.ts new file mode 100644 index 000000000..d924d93a4 --- /dev/null +++ b/formulus-formplayer/src/theme/choiceLayout.ts @@ -0,0 +1,84 @@ +import type { SxProps, Theme } from '@mui/material'; + +export type ColumnCount = 2 | 3 | 4 | 5; + +export type ChoiceLayout = + | { mode: 'vertical' } + | { mode: 'horizontal' } + | { mode: 'flow' } + | { mode: 'columns'; columns: ColumnCount }; + +const COLS_PATTERN = /^cols-([2-5])$/; + +export function parseChoiceLayout( + options?: Record, +): ChoiceLayout { + const orientation = options?.orientation; + if (orientation === 'horizontal' || orientation === 'flow') { + return { mode: orientation }; + } + if (typeof orientation === 'string') { + const match = COLS_PATTERN.exec(orientation); + if (match) { + return { mode: 'columns', columns: Number(match[1]) as ColumnCount }; + } + } + return { mode: 'vertical' }; +} + +const formControlLabelRowSx = { + '& .MuiFormControlLabel-root': { + width: 'auto', + marginRight: 0.5, + }, +}; + +export function choiceListSx(layout: ChoiceLayout): SxProps { + if (layout.mode === 'columns') { + return { + display: 'grid', + gridTemplateColumns: `repeat(${layout.columns}, minmax(0, 1fr))`, + gap: 0.5, + alignItems: 'start', + ...formControlLabelRowSx, + }; + } + + return { + display: 'flex', + flexDirection: layout.mode === 'vertical' ? 'column' : 'row', + flexWrap: layout.mode === 'flow' ? 'wrap' : 'nowrap', + gap: layout.mode === 'vertical' ? 0 : 0.5, + alignItems: layout.mode === 'vertical' ? undefined : 'center', + ...(layout.mode !== 'vertical' ? formControlLabelRowSx : {}), + }; +} + +/** ToggleButtonGroup only supports vertical/horizontal; cols-* falls back to flow wrap. */ +export function toggleButtonListSx( + layout: ChoiceLayout, + separated: boolean, +): SxProps { + const wrap = + layout.mode === 'flow' || layout.mode === 'columns' ? 'wrap' : 'nowrap'; + return { + flexWrap: wrap, + ...(separated + ? { + gap: 1, + '& .MuiToggleButtonGroup-grouped': { + border: '1px solid', + borderColor: 'divider', + borderRadius: 1, + '&:not(:first-of-type)': { marginLeft: 0 }, + }, + } + : {}), + }; +} + +export function toggleButtonOrientation( + layout: ChoiceLayout, +): 'vertical' | 'horizontal' { + return layout.mode === 'vertical' ? 'vertical' : 'horizontal'; +} diff --git a/formulus-formplayer/src/theme/material-wrappers.tsx b/formulus-formplayer/src/theme/material-wrappers.tsx index 05dbc2f56..b9b0df3f5 100644 --- a/formulus-formplayer/src/theme/material-wrappers.tsx +++ b/formulus-formplayer/src/theme/material-wrappers.tsx @@ -38,6 +38,13 @@ import { import QuestionShell from '../components/QuestionShell'; import { isControlHidden } from '../jsonforms/visibleGuard'; import { tokens } from './tokens-adapter'; +import { + parseChoiceLayout, + choiceListSx, + toggleButtonListSx, + toggleButtonOrientation, +} from './choiceLayout'; +import { ChoiceOptionList } from './ChoiceOptionList'; const parsePx = (value: string): number => parseInt(String(value).replace('px', ''), 10) || 1; @@ -326,7 +333,7 @@ const EnumArrayShellControl = ( description={description} required={required} error={errors}> - + {options.map(opt => ( label: o.title ?? String(o.const ?? o), })) || (schema.enum || []).map((v: any) => ({ value: v, label: String(v) })); -type ChoiceOrientation = 'vertical' | 'horizontal' | 'flow'; - const readChoiceLayout = (uischema: any) => { const o = uischema?.options ?? {}; - const orientation: ChoiceOrientation = - o.orientation === 'horizontal' || o.orientation === 'flow' - ? o.orientation - : 'vertical'; + const layout = parseChoiceLayout(o); const separated = o.buttonGroup === 'separated'; - return { orientation, separated }; + return { layout, separated }; }; -const choiceListSx = (orientation: ChoiceOrientation) => ({ - display: 'flex', - flexDirection: orientation === 'vertical' ? 'column' : 'row', - flexWrap: orientation === 'flow' ? 'wrap' : 'nowrap', - gap: orientation === 'vertical' ? 0 : 0.5, -}); - -const toggleGroupSx = (orientation: ChoiceOrientation, separated: boolean) => ({ - flexWrap: orientation === 'flow' ? 'wrap' : 'nowrap', - ...(separated - ? { - gap: 1, - '& .MuiToggleButtonGroup-grouped': { - border: '1px solid', - borderColor: 'divider', - borderRadius: 1, - '&:not(:first-of-type)': { marginLeft: 0 }, - }, - } - : {}), -}); - export const choiceControlTester: RankedTester = rankWith( 7, and( @@ -430,7 +410,7 @@ export const ChoiceControl = (props: AnyControlProps) => { (uischema as any)?.options?.required ?? (schema as any)?.options?.required, ); const display = (uischema as any)?.options?.display; - const { orientation, separated } = readChoiceLayout(uischema); + const { layout, separated } = readChoiceLayout(uischema); const options = deriveChoiceOptions(schema); const body = @@ -438,7 +418,7 @@ export const ChoiceControl = (props: AnyControlProps) => { { if (!enabled) return; @@ -446,7 +426,7 @@ export const ChoiceControl = (props: AnyControlProps) => { // which gives tap-to-clear for free. handleChange(path, val == null ? undefined : val); }} - sx={toggleGroupSx(orientation, separated)}> + sx={toggleButtonListSx(layout, separated)}> {options.map(opt => ( {opt.label} @@ -454,7 +434,7 @@ export const ChoiceControl = (props: AnyControlProps) => { ))} ) : ( - + {options.map(opt => { const selected = data === opt.value; return ( @@ -475,7 +455,7 @@ export const ChoiceControl = (props: AnyControlProps) => { /> ); })} - + ); return ( @@ -518,7 +498,7 @@ export const MultiChoiceControl = ( const description = schema.description; const required = Boolean((uischema as any)?.options?.required); const display = (uischema as any)?.options?.display; - const { orientation, separated } = readChoiceLayout(uischema); + const { layout, separated } = readChoiceLayout(uischema); const selected: unknown[] = Array.isArray(data) ? data : []; const isSelected = (v: unknown) => selected.includes(v); const toggle = (v: unknown) => { @@ -531,7 +511,7 @@ export const MultiChoiceControl = ( display === 'buttons' ? ( { if (!enabled) return; @@ -542,7 +522,7 @@ export const MultiChoiceControl = ( .filter(v => !newVals.includes(v)) .forEach(v => removeItem?.(path, v)); }} - sx={toggleGroupSx(orientation, separated)}> + sx={toggleButtonListSx(layout, separated)}> {options.map(opt => ( {opt.label} @@ -550,7 +530,7 @@ export const MultiChoiceControl = ( ))} ) : ( - + {options.map(opt => ( ))} - + ); return (