Skip to content

Commit edcafc4

Browse files
fix object config with many elements rendering
1 parent 7b0b918 commit edcafc4

5 files changed

Lines changed: 211 additions & 19 deletions

File tree

web_client/src/components/common/ListDisplay/index.stories.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import type { Meta, StoryObj } from '@storybook/react-vite';
22
import NumberInput from '../NumberInput';
3+
import ObjectDisplay from '../ObjectDisplay';
4+
import TextInput from '../TextInput';
35
import ListDisplay from '.';
46

57
const meta: Meta<typeof ListDisplay> = {
@@ -62,3 +64,24 @@ export const WithManyItems: Story = {
6264
onRemove: (index) => console.log('Remove', index),
6365
},
6466
};
67+
68+
const noop = () => {};
69+
70+
export const DividedWithObjects: Story = {
71+
args: {
72+
children: Array.from({ length: 4 }, (_, i) => (
73+
<ObjectDisplay key={`obj-${i + 1}`}>
74+
{[
75+
<TextInput key='name' value={`Pump ${i + 1}`} handleInputChange={noop} />,
76+
<NumberInput key='pin' value={i + 10} handleInputChange={noop} suffix='pin' />,
77+
<NumberInput key='volume' value={(i + 1) * 100} handleInputChange={noop} suffix='ml' />,
78+
]}
79+
</ObjectDisplay>
80+
)),
81+
divided: true,
82+
immutable: false,
83+
defaultValue: '',
84+
onAdd: (value) => console.log('Add', value),
85+
onRemove: (index) => console.log('Remove', index),
86+
},
87+
};

web_client/src/components/common/ListDisplay/index.tsx

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,30 @@ import CloseButton from '../CloseButton';
55
interface ListDisplayProps<T = PossibleConfigValue> {
66
children: React.ReactNode[];
77
defaultValue: T;
8+
divided?: boolean;
89
immutable: boolean;
910
onAdd?: (value: T) => void;
1011
onRemove?: (index: number) => void;
1112
}
1213

13-
const ListDisplay = ({ children, defaultValue, immutable, onAdd, onRemove }: ListDisplayProps) => {
14+
const divideClasses = 'divide-y divide-[color-mix(in_srgb,var(--neutral-color)_50%,transparent)]';
15+
16+
const ListDisplay = ({ children, defaultValue, divided, immutable, onAdd, onRemove }: ListDisplayProps) => {
1417
return (
1518
<div className='flex flex-col items-center w-full'>
16-
{children.map((item, index) => (
17-
// biome-ignore lint/suspicious/noArrayIndexKey: is always ordered here
18-
<div key={index} className='flex items-center mb-1 w-full'>
19-
<span className='mr-1 font-bold text-secondary min-w-4'>{index + 1}</span>
20-
{item}
21-
{!immutable && <CloseButton iconSize={33} onClick={() => onRemove?.(index)} />}
22-
</div>
23-
))}
19+
<div className={`flex flex-col w-full ${divided ? divideClasses : ''}`}>
20+
{children.map((item, index) => (
21+
<div
22+
// biome-ignore lint/suspicious/noArrayIndexKey: is always ordered here
23+
key={index}
24+
className={`flex items-center w-full ${divided ? 'p-1.5' : 'p-0.5'}`}
25+
>
26+
<span className='mx-1 font-bold text-secondary min-w-4'>{index + 1}</span>
27+
{item}
28+
{!immutable && <CloseButton iconSize={33} onClick={() => onRemove?.(index)} />}
29+
</div>
30+
))}
31+
</div>
2432
{!immutable && (
2533
<button
2634
type='button'
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import type { Meta, StoryObj } from '@storybook/react-vite';
2+
import CheckBox from '../CheckBox';
3+
import NumberInput from '../NumberInput';
4+
import TextInput from '../TextInput';
5+
import ObjectDisplay from '.';
6+
7+
const meta: Meta<typeof ObjectDisplay> = {
8+
title: 'Elements/ObjectDisplay',
9+
component: ObjectDisplay,
10+
parameters: {
11+
layout: 'centered',
12+
},
13+
tags: ['autodocs'],
14+
argTypes: {
15+
children: { control: false },
16+
},
17+
};
18+
19+
export default meta;
20+
type Story = StoryObj<typeof ObjectDisplay>;
21+
22+
const noop = () => {};
23+
24+
export const TwoItems: Story = {
25+
args: {
26+
children: [
27+
<NumberInput key='pin' value={14} handleInputChange={noop} suffix='pin' />,
28+
<NumberInput key='volume' value={250} handleInputChange={noop} suffix='ml' />,
29+
],
30+
},
31+
};
32+
33+
export const ThreeItems: Story = {
34+
args: {
35+
children: [
36+
<TextInput key='name' value='Pump 1' handleInputChange={noop} />,
37+
<NumberInput key='pin' value={14} handleInputChange={noop} suffix='pin' />,
38+
<NumberInput key='volume' value={250} handleInputChange={noop} suffix='ml' />,
39+
],
40+
},
41+
};
42+
43+
export const FourItems: Story = {
44+
args: {
45+
children: [
46+
<TextInput key='name' value='Pump 1' handleInputChange={noop} />,
47+
<NumberInput key='pin' value={14} handleInputChange={noop} suffix='pin' />,
48+
<NumberInput key='volume' value={250} handleInputChange={noop} suffix='ml' />,
49+
<CheckBox key='enabled' value={true} checkName='on' handleInputChange={noop} />,
50+
],
51+
},
52+
};
53+
54+
export const FiveItems: Story = {
55+
args: {
56+
children: [
57+
<TextInput key='name' value='Pump 1' handleInputChange={noop} />,
58+
<NumberInput key='pin' value={14} handleInputChange={noop} suffix='pin' />,
59+
<NumberInput key='volume' value={250} handleInputChange={noop} suffix='ml' />,
60+
<NumberInput key='speed' value={100} handleInputChange={noop} suffix='%' />,
61+
<CheckBox key='enabled' value={true} checkName='on' handleInputChange={noop} />,
62+
],
63+
},
64+
};
65+
66+
export const SevenItems: Story = {
67+
args: {
68+
children: [
69+
<TextInput key='name' value='Pump 1' handleInputChange={noop} />,
70+
<NumberInput key='pin' value={14} handleInputChange={noop} suffix='pin' />,
71+
<NumberInput key='volume' value={250} handleInputChange={noop} suffix='ml' />,
72+
<NumberInput key='speed' value={100} handleInputChange={noop} suffix='%' />,
73+
<NumberInput key='delay' value={50} handleInputChange={noop} suffix='ms' />,
74+
<TextInput key='type' value='peristaltic' handleInputChange={noop} />,
75+
<CheckBox key='enabled' value={true} checkName='on' handleInputChange={noop} />,
76+
],
77+
},
78+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { useEffect, useMemo, useRef, useState } from 'react';
2+
3+
interface ObjectDisplayProps {
4+
children: React.ReactNode[];
5+
}
6+
7+
/** Determine the best number of columns so rows are evenly filled.
8+
* maxPerRow is the upper bound (based on screen width), itemCount the total items.
9+
* Returns a value <= maxPerRow where itemCount % cols === 0, or the best fit. */
10+
const evenColumns = (itemCount: number, maxPerRow: number): number => {
11+
if (itemCount <= maxPerRow) return itemCount;
12+
// prefer a divisor of itemCount that is <= maxPerRow for perfectly even rows
13+
for (let cols = maxPerRow; cols >= 2; cols--) {
14+
if (itemCount % cols === 0) return cols;
15+
}
16+
// fallback: pick cols that minimizes the difference between row sizes
17+
let best = maxPerRow;
18+
let bestDiff = maxPerRow;
19+
for (let cols = maxPerRow; cols >= 2; cols--) {
20+
const rows = Math.ceil(itemCount / cols);
21+
const lastRow = itemCount - cols * (rows - 1);
22+
const diff = cols - lastRow;
23+
if (diff < bestDiff) {
24+
bestDiff = diff;
25+
best = cols;
26+
}
27+
}
28+
return best;
29+
};
30+
31+
const ObjectDisplay = ({ children }: ObjectDisplayProps) => {
32+
const containerRef = useRef<HTMLDivElement>(null);
33+
const [containerWidth, setContainerWidth] = useState(0);
34+
35+
useEffect(() => {
36+
const el = containerRef.current;
37+
if (!el) return;
38+
const observer = new ResizeObserver((entries) => {
39+
for (const entry of entries) {
40+
setContainerWidth(entry.contentRect.width);
41+
}
42+
});
43+
observer.observe(el);
44+
return () => observer.disconnect();
45+
}, []);
46+
47+
// Max items that fit per row based on the container's own width
48+
const maxItemsPerRow = useMemo(() => {
49+
if (containerWidth < 480) return 4;
50+
if (containerWidth < 768) return 5;
51+
return 6;
52+
}, [containerWidth]);
53+
54+
const cols = evenColumns(children.length, maxItemsPerRow);
55+
const remainder = children.length % cols;
56+
const gridChildren = remainder === 0 ? children : children.slice(0, -remainder);
57+
const lastRowChildren = remainder === 0 ? [] : children.slice(-remainder);
58+
59+
return (
60+
<div ref={containerRef} className='w-full flex flex-col gap-y-1'>
61+
{gridChildren.length > 0 && (
62+
<div className='grid w-full' style={{ gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` }}>
63+
{gridChildren.map((child, index) => (
64+
// biome-ignore lint/suspicious/noArrayIndexKey: items are always ordered here
65+
<div key={index} className='flex items-center w-full'>
66+
{child}
67+
</div>
68+
))}
69+
</div>
70+
)}
71+
{lastRowChildren.length > 0 && (
72+
<div className='flex w-full'>
73+
{lastRowChildren.map((child, index) => (
74+
// biome-ignore lint/suspicious/noArrayIndexKey: items are always ordered here
75+
<div key={index} className='flex items-center flex-1'>
76+
{child}
77+
</div>
78+
))}
79+
</div>
80+
)}
81+
</div>
82+
);
83+
};
84+
85+
export default ObjectDisplay;

web_client/src/components/options/ConfigWindow.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,19 @@ import { useTranslation } from 'react-i18next';
44
import { FaSave } from 'react-icons/fa';
55
import { updateOptions, useConfig } from '../../api/options';
66
import { useConfig as useConfigProvider } from '../../providers/ConfigProvider';
7-
import type { ConfigData, PossibleConfigValue, PossibleConfigValueTypes } from '../../types/models';
8-
import { executeAndShow, isInCurrentTab, isInCurrentSubTab, subTabConfig, OPTIONTABS } from '../../utils';
97
import { useRestrictedMode } from '../../providers/RestrictedModeProvider';
8+
import type { ConfigData, PossibleConfigValue, PossibleConfigValueTypes } from '../../types/models';
9+
import { executeAndShow, isInCurrentSubTab, isInCurrentTab, OPTIONTABS, subTabConfig } from '../../utils';
1010
import CheckBox from '../common/CheckBox';
1111
import ColorSelect from '../common/ColorSelect';
1212
import DropDown from '../common/DropDown';
1313
import ErrorComponent from '../common/ErrorComponent';
1414
import ListDisplay from '../common/ListDisplay';
1515
import LoadingData from '../common/LoadingData';
1616
import NumberInput from '../common/NumberInput';
17-
import TextInput from '../common/TextInput';
17+
import ObjectDisplay from '../common/ObjectDisplay';
1818
import TabSelector from '../common/TabSelector';
19+
import TextInput from '../common/TextInput';
1920

2021
// some of the config are "old" meaning they are only used in the QT but not React UI
2122
// we will define them here and skip the values for those (e.g. not generate input fields)
@@ -168,13 +169,9 @@ const ConfigWindow: React.FC = () => {
168169
};
169170

170171
const renderObjectField = (key: string, value: { [key: string]: PossibleConfigValueTypes }) => (
171-
<div className='flex flex-row w-full'>
172-
{Object.keys(value).map((subKey) => (
173-
<div key={subKey} className='flex items-center w-full'>
174-
{renderInputField(`${key}.${subKey}`, value[subKey])}
175-
</div>
176-
))}
177-
</div>
172+
<ObjectDisplay>
173+
{Object.keys(value).map((subKey) => renderInputField(`${key}.${subKey}`, value[subKey]))}
174+
</ObjectDisplay>
178175
);
179176

180177
const renderColorField = (key: string, value: string) => {
@@ -187,6 +184,7 @@ const ConfigWindow: React.FC = () => {
187184
return (
188185
<ListDisplay
189186
defaultValue={defaultValue}
187+
divided={value.length > 0 && typeof value[0] === 'object' && !Array.isArray(value[0])}
190188
immutable={baseConfig.immutable}
191189
onAdd={(value) => handleAddItem(key, value)}
192190
onRemove={(index) => handleRemoveItem(key, index)}

0 commit comments

Comments
 (0)