Skip to content

Commit ea2083f

Browse files
authored
feat: multi-select Group filter with Select All / Clear All (#149)
1 parent 3442e32 commit ea2083f

File tree

3 files changed

+83
-117
lines changed

3 files changed

+83
-117
lines changed

thingconnect.pulse.client/src/components/common/ComboboxSelect.tsx

Lines changed: 53 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useEffect, useMemo, type ComponentProps } from 'react';
2-
import { Combobox, HStack, Portal, Span, Spinner, Skeleton } from '@chakra-ui/react';
2+
import { Combobox, HStack, Portal, Span, Spinner, Skeleton, Text, Button } from '@chakra-ui/react';
33
import { useFilter, useListCollection } from '@chakra-ui/react';
44

55
type Option = {
@@ -10,14 +10,15 @@ type Option = {
1010
export interface ComboboxProps
1111
extends Omit<
1212
ComponentProps<typeof Combobox.Root>,
13-
'onChange' | 'children' | 'value' | 'collection'
13+
'onChange' | 'children' | 'value' | 'collection' | 'multiple'
1414
> {
1515
items: Option[];
16-
selectedValue?: string;
17-
onChange: (value: string) => void;
16+
selectedValue?: string | string[];
17+
onChange: (value: string | string[]) => void;
1818
placeholder?: string;
1919
isLoading?: boolean;
2020
defaultToFirst?: boolean;
21+
isMulti?: boolean;
2122
}
2223

2324
export function ComboboxSelect({
@@ -27,11 +28,10 @@ export function ComboboxSelect({
2728
placeholder = 'Select an option',
2829
isLoading = false,
2930
defaultToFirst = false,
31+
isMulti = false,
3032
...rest
3133
}: ComboboxProps) {
32-
const itemsWithAll = useMemo(() => {
33-
return items.length > 0 && !defaultToFirst ? [{ label: 'All', value: '' }, ...items] : items;
34-
}, [items, defaultToFirst]);
34+
const itemsWithAll = useMemo(() => items, [items]);
3535

3636
const { contains } = useFilter({ sensitivity: 'base' });
3737
const { collection, set, filter } = useListCollection<Option>({
@@ -42,24 +42,24 @@ export function ComboboxSelect({
4242
});
4343

4444
useEffect(() => {
45-
// Always update the collection
4645
set(itemsWithAll);
4746

48-
// If defaultToFirst is true and nothing is selected yet, pick the first option
4947
if (defaultToFirst && !selectedValue && itemsWithAll.length > 0) {
50-
onChange(itemsWithAll[0].value);
48+
onChange(isMulti ? [itemsWithAll[0].value] : itemsWithAll[0].value);
5149
}
52-
}, [itemsWithAll, set, defaultToFirst, selectedValue, onChange]);
50+
}, [itemsWithAll, set, defaultToFirst, selectedValue, onChange, isMulti]);
5351

5452
return (
5553
<Skeleton loading={isLoading} w='full'>
5654
<Combobox.Root
5755
size='md'
5856
w='xs'
5957
collection={collection}
60-
value={[selectedValue]}
58+
value={Array.isArray(selectedValue) ? selectedValue : [selectedValue]}
59+
multiple={isMulti}
60+
closeOnSelect={!isMulti}
6161
onValueChange={e => {
62-
const newValue = e.value[0] ?? '';
62+
const newValue = isMulti ? e.value : (e.value[0] ?? '');
6363
onChange(newValue);
6464
}}
6565
onInputValueChange={e => filter(e.inputValue)}
@@ -72,14 +72,17 @@ export function ComboboxSelect({
7272
<Combobox.Control>
7373
<Combobox.Input
7474
placeholder={placeholder || 'Select an option'}
75-
value={collection.items.find(item => item.value === selectedValue)?.label || ''}
75+
value={
76+
isMulti
77+
? collection.items
78+
.filter(item => (selectedValue as string[]).includes(item.value))
79+
.map(i => i.label)
80+
.join(', ')
81+
: collection.items.find(item => item.value === selectedValue)?.label || ''
82+
}
7683
/>
7784
<Combobox.IndicatorGroup>
78-
<Combobox.ClearTrigger
79-
onClick={() => {
80-
onChange('');
81-
}}
82-
/>
85+
<Combobox.ClearTrigger onClick={() => onChange(isMulti ? [] : '')} />
8386
<Combobox.Trigger />
8487
</Combobox.IndicatorGroup>
8588
</Combobox.Control>
@@ -94,14 +97,38 @@ export function ComboboxSelect({
9497
) : collection.items.length === 0 ? (
9598
<Combobox.Empty>No options found</Combobox.Empty>
9699
) : (
97-
collection.items.map(item => (
98-
<Combobox.Item key={item.value} item={item}>
99-
<HStack justify='space-between' textStyle='sm'>
100-
{item.label}
100+
<>
101+
{isMulti && (
102+
<HStack justify='flex-start' colorPalette={'blue'} gap={0}>
103+
<Button
104+
size='2xs'
105+
variant='plain'
106+
onClick={() => onChange(collection.items.map(i => i.value))}
107+
textDecoration='underline'
108+
fontWeight={'light'}
109+
>
110+
Select All
111+
</Button>
112+
<Button
113+
size='2xs'
114+
variant='plain'
115+
onClick={() => onChange([])}
116+
textDecoration='underline'
117+
fontWeight={'light'}
118+
>
119+
<Text>Clear All</Text>
120+
</Button>
101121
</HStack>
102-
<Combobox.ItemIndicator />
103-
</Combobox.Item>
104-
))
122+
)}
123+
{collection.items.map(item => (
124+
<Combobox.Item key={item.value} item={item}>
125+
<HStack justify='space-between' textStyle='sm'>
126+
{item.label}
127+
</HStack>
128+
<Combobox.ItemIndicator />
129+
</Combobox.Item>
130+
))}
131+
</>
105132
)}
106133
</Combobox.Content>
107134
</Combobox.Positioner>

thingconnect.pulse.client/src/components/status/EndpointFilters.tsx

Lines changed: 26 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { Box, Button, Flex, HStack, Icon, Input, Text, Menu } from '@chakra-ui/react';
1+
import { Box, Flex, HStack, Icon, Input } from '@chakra-ui/react';
22
import { X } from 'lucide-react';
33
import type { LiveStatusParams } from '@/api/types';
4-
import { MdSearch, MdExpandMore } from 'react-icons/md';
4+
import { MdSearch } from 'react-icons/md';
55
import { ComboboxSelect } from '../common/ComboboxSelect';
66

77
interface EndpointFiltersProps {
@@ -31,12 +31,13 @@ export function EndpointFilters({
3131
selectedGroup,
3232
onSelectedGroupChange,
3333
}: EndpointFiltersProps) {
34-
const handleGroupChange = (value: string) => {
35-
const newGroup = value || undefined;
36-
onSelectedGroupChange?.(newGroup);
34+
const handleGroupChange = (value: string | string[]) => {
35+
const newGroup = Array.isArray(value) ? value[0] : value;
36+
const finalGroup = newGroup || undefined;
37+
onSelectedGroupChange?.(finalGroup);
3738
onFiltersChange({
3839
...filters,
39-
group: newGroup,
40+
group: finalGroup,
4041
});
4142
};
4243

@@ -128,90 +129,25 @@ export function EndpointFilters({
128129
</Flex>
129130
</Box>
130131
{/* Group By Dropdown */}
131-
<Box>
132-
<Menu.Root>
133-
<Menu.Trigger asChild>
134-
<Button variant='outline'>
135-
<Text fontSize='sm'>
136-
{groupByOptions.length > 0
137-
? groupByOptions
138-
.map(opt => (opt === 'status' ? 'Status' : opt === 'group' ? 'Group' : opt))
139-
.join(' + ')
140-
: 'Group By'}
141-
</Text>
142-
<MdExpandMore />
143-
</Button>
144-
</Menu.Trigger>
145-
<Menu.Positioner px={4}>
146-
<Menu.Content minWidth='200px'>
147-
<Flex
148-
justify='flex-end'
149-
px={2}
150-
py={1}
151-
borderBottom='1px solid'
152-
borderColor='gray.200'
153-
_dark={{ borderColor: 'gray.600' }}
154-
>
155-
<HStack gap={2}>
156-
<Button
157-
size='xs'
158-
variant='ghost'
159-
onClick={() => {
160-
['status', 'group'].forEach(
161-
opt => onToggleGroupBy && onToggleGroupBy(opt, true)
162-
);
163-
}}
164-
textDecoration={'underline'}
165-
>
166-
Select All
167-
</Button>
168-
<Button
169-
size='xs'
170-
variant='ghost'
171-
onClick={() => {
172-
['status', 'group'].forEach(
173-
opt => onToggleGroupBy && onToggleGroupBy(opt, false)
174-
);
175-
}}
176-
textDecoration={'underline'}
177-
>
178-
Clear
179-
</Button>
180-
</HStack>
181-
</Flex>
182-
<Menu.ItemGroup>
183-
<Menu.CheckboxItem
184-
cursor={'pointer'}
185-
value='status'
186-
checked={groupByOptions.includes('status')}
187-
onCheckedChange={() => {
188-
onToggleGroupBy &&
189-
onToggleGroupBy('status', !groupByOptions.includes('status'));
190-
}}
191-
>
192-
<Flex w='full' justify='flex-start' align='center' gap={3}>
193-
<Text as='span'>Group by Status</Text>
194-
<Menu.ItemIndicator />
195-
</Flex>
196-
</Menu.CheckboxItem>
197-
<Menu.CheckboxItem
198-
cursor={'pointer'}
199-
value='group'
200-
checked={groupByOptions.includes('group')}
201-
onCheckedChange={() => {
202-
onToggleGroupBy &&
203-
onToggleGroupBy('group', !groupByOptions.includes('group'));
204-
}}
205-
>
206-
<Flex w='full' justify='flex-start' align='center' gap={3}>
207-
<Text as='span'>Group by Group</Text>
208-
<Menu.ItemIndicator />
209-
</Flex>
210-
</Menu.CheckboxItem>
211-
</Menu.ItemGroup>
212-
</Menu.Content>
213-
</Menu.Positioner>
214-
</Menu.Root>
132+
<Box w='xs'>
133+
<ComboboxSelect
134+
items={[
135+
{ label: 'Status', value: 'status' },
136+
{ label: 'Group', value: 'group' },
137+
]}
138+
selectedValue={groupByOptions}
139+
onChange={(values: string | string[]) => {
140+
const selected = Array.isArray(values) ? values : values ? [values] : [];
141+
142+
['status', 'group'].forEach(opt => {
143+
const isSelected = selected.includes(opt);
144+
onToggleGroupBy?.(opt, isSelected);
145+
});
146+
}}
147+
placeholder='Group By'
148+
defaultToFirst={false}
149+
isMulti={true}
150+
/>
215151
</Box>
216152
</HStack>
217153
</Box>

thingconnect.pulse.client/src/pages/History.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,10 @@ export default function History() {
117117
</Text>
118118
<EndpointSelect
119119
selectedValue={selectedEndpoint}
120-
onChange={setSelectedEndpoint}
120+
onChange={(value: string | string[]) => {
121+
const endpoint = Array.isArray(value) ? value[0] : value;
122+
setSelectedEndpoint(endpoint);
123+
}}
121124
setName={setSelectedEndpointName}
122125
defaultToFirst={true}
123126
w={'xs'}

0 commit comments

Comments
 (0)