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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1021,6 +1021,21 @@ fn mirror_custom_app_dev_folder(ws: &Path, source: &Path) -> Result<u64, Custodi
let mirror_forms = dev_local.join("forms");
copy_dir_recursive_counting(&forms_src, &mirror_forms, &mut copied_files)?;
}
let mirror_qt = mirror_app.join("question_types");
if !mirror_qt.is_dir() {
let qt_candidates = [
source.join("question_types"),
source.join("public").join("question_types"),
source.join("..").join("public").join("question_types"),
source.join("..").join("..").join("question_types"),
];
for qt_src in qt_candidates {
if qt_src.is_dir() {
copy_dir_recursive_counting(&qt_src, &mirror_qt, &mut copied_files)?;
break;
}
}
}
Ok(copied_files)
}

Expand Down
25 changes: 25 additions & 0 deletions formulus-formplayer/src/theme/ChoiceOptionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ReactNode } from 'react';
import { Box, FormGroup } from '@mui/material';
import { choiceListSx, type ChoiceLayout } from './choiceLayout';

type ChoiceOptionListProps = {
layout: ChoiceLayout;
role?: 'group' | 'radiogroup';
children: ReactNode;
};

/** FormGroup theme sets flexDirection column; use Box for non-vertical layouts. */
export function ChoiceOptionList({
layout,
role = 'group',
children,
}: ChoiceOptionListProps) {
if (layout.mode === 'vertical') {
return <FormGroup sx={choiceListSx(layout)}>{children}</FormGroup>;
}
return (
<Box role={role} sx={choiceListSx(layout)}>
{children}
</Box>
);
}
82 changes: 82 additions & 0 deletions formulus-formplayer/src/theme/choiceLayout.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<string, unknown>;
expect(sx.flexWrap).toBe('wrap');
});
});
84 changes: 84 additions & 0 deletions formulus-formplayer/src/theme/choiceLayout.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
): 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<Theme> {
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<Theme> {
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';
}
62 changes: 21 additions & 41 deletions formulus-formplayer/src/theme/material-wrappers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -326,7 +333,7 @@ const EnumArrayShellControl = (
description={description}
required={required}
error={errors}>
<FormGroup sx={choiceListSx('vertical')}>
<FormGroup sx={choiceListSx({ mode: 'vertical' })}>
{options.map(opt => (
<FormControlLabel
key={String(opt.value)}
Expand All @@ -351,7 +358,7 @@ const EnumArrayShellControl = (
// options.display:
// single-select (enum / oneOf): "radio" | "buttons"
// multi-select (array enum): "checkboxes" | "buttons"
// options.orientation: "vertical" (default) | "horizontal" | "flow" (wrap in inline layout)
// options.orientation: "vertical" (default) | "horizontal" | "flow" | "cols-2" … "cols-5"
// options.buttonGroup: "segmented" (default) | "separated"
//
// Single-select radio/buttons support tap-the-selected-option-to-clear.
Expand All @@ -368,40 +375,13 @@ const deriveChoiceOptions = (schema: any): ChoiceOption[] =>
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(
Expand Down Expand Up @@ -430,31 +410,31 @@ 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 =
display === 'buttons' ? (
<ToggleButtonGroup
exclusive
disabled={!enabled}
orientation={orientation === 'vertical' ? 'vertical' : 'horizontal'}
orientation={toggleButtonOrientation(layout)}
value={data ?? null}
onChange={(_e, val) => {
if (!enabled) return;
// Exclusive group returns null when re-clicking the active option,
// which gives tap-to-clear for free.
handleChange(path, val == null ? undefined : val);
}}
sx={toggleGroupSx(orientation, separated)}>
sx={toggleButtonListSx(layout, separated)}>
{options.map(opt => (
<ToggleButton key={String(opt.value)} value={opt.value as any}>
{opt.label}
</ToggleButton>
))}
</ToggleButtonGroup>
) : (
<Box role="radiogroup" sx={choiceListSx(orientation)}>
<ChoiceOptionList layout={layout} role="radiogroup">
{options.map(opt => {
const selected = data === opt.value;
return (
Expand All @@ -475,7 +455,7 @@ export const ChoiceControl = (props: AnyControlProps) => {
/>
);
})}
</Box>
</ChoiceOptionList>
);

return (
Expand Down Expand Up @@ -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) => {
Expand All @@ -531,7 +511,7 @@ export const MultiChoiceControl = (
display === 'buttons' ? (
<ToggleButtonGroup
disabled={!enabled}
orientation={orientation === 'vertical' ? 'vertical' : 'horizontal'}
orientation={toggleButtonOrientation(layout)}
value={selected as any}
onChange={(_e, newVals: unknown[]) => {
if (!enabled) return;
Expand All @@ -542,15 +522,15 @@ export const MultiChoiceControl = (
.filter(v => !newVals.includes(v))
.forEach(v => removeItem?.(path, v));
}}
sx={toggleGroupSx(orientation, separated)}>
sx={toggleButtonListSx(layout, separated)}>
{options.map(opt => (
<ToggleButton key={String(opt.value)} value={opt.value as any}>
{opt.label}
</ToggleButton>
))}
</ToggleButtonGroup>
) : (
<FormGroup sx={choiceListSx(orientation)}>
<ChoiceOptionList layout={layout}>
{options.map(opt => (
<FormControlLabel
key={String(opt.value)}
Expand All @@ -564,7 +544,7 @@ export const MultiChoiceControl = (
label={opt.label}
/>
))}
</FormGroup>
</ChoiceOptionList>
);

return (
Expand Down
Loading