Skip to content

Commit 35908d2

Browse files
authored
feat: Improve Combobox and Endpoint Selection Components
2 parents 1e733bd + 11df615 commit 35908d2

File tree

11 files changed

+856
-783
lines changed

11 files changed

+856
-783
lines changed
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { useEffect, useMemo, type ComponentProps } from 'react';
2+
import { Combobox, HStack, Portal, Span, Spinner, Skeleton } from '@chakra-ui/react';
3+
import { useFilter, useListCollection } from '@chakra-ui/react';
4+
5+
type Option = {
6+
label: string;
7+
value: string;
8+
};
9+
10+
export interface ComboboxProps
11+
extends Omit<
12+
ComponentProps<typeof Combobox.Root>,
13+
'onChange' | 'children' | 'value' | 'collection'
14+
> {
15+
items: Option[];
16+
selectedValue?: string;
17+
onChange: (value: string) => void;
18+
placeholder?: string;
19+
isLoading?: boolean;
20+
defaultToFirst?: boolean;
21+
}
22+
23+
export function ComboboxSelect({
24+
items,
25+
selectedValue = '',
26+
onChange,
27+
placeholder = 'Select an option',
28+
isLoading = false,
29+
defaultToFirst = false,
30+
...rest
31+
}: ComboboxProps) {
32+
const itemsWithAll = useMemo(() => {
33+
return items.length > 0 && !defaultToFirst ? [{ label: 'All', value: '' }, ...items] : items;
34+
}, [items, defaultToFirst]);
35+
36+
const { contains } = useFilter({ sensitivity: 'base' });
37+
const { collection, set, filter } = useListCollection<Option>({
38+
initialItems: [],
39+
itemToString: item => item.label,
40+
itemToValue: item => item.value,
41+
filter: contains,
42+
});
43+
44+
useEffect(() => {
45+
// Always update the collection
46+
set(itemsWithAll);
47+
48+
// If defaultToFirst is true and nothing is selected yet, pick the first option
49+
if (defaultToFirst && !selectedValue && itemsWithAll.length > 0) {
50+
onChange(itemsWithAll[0].value);
51+
}
52+
}, [itemsWithAll, set, defaultToFirst, selectedValue, onChange]);
53+
54+
return (
55+
<Skeleton loading={isLoading} w='full'>
56+
<Combobox.Root
57+
size='md'
58+
w='xs'
59+
collection={collection}
60+
value={[selectedValue]}
61+
onValueChange={e => {
62+
const newValue = e.value[0] ?? '';
63+
onChange(newValue);
64+
}}
65+
onInputValueChange={e => filter(e.inputValue)}
66+
onOpenChange={open => {
67+
if (open) filter('');
68+
}}
69+
openOnClick
70+
{...rest}
71+
>
72+
<Combobox.Control>
73+
<Combobox.Input
74+
placeholder={placeholder || 'Select an option'}
75+
value={collection.items.find(item => item.value === selectedValue)?.label || ''}
76+
/>
77+
<Combobox.IndicatorGroup>
78+
<Combobox.ClearTrigger
79+
onClick={() => {
80+
onChange('');
81+
}}
82+
/>
83+
<Combobox.Trigger />
84+
</Combobox.IndicatorGroup>
85+
</Combobox.Control>
86+
<Portal>
87+
<Combobox.Positioner>
88+
<Combobox.Content minW='sm'>
89+
{isLoading ? (
90+
<HStack p='2'>
91+
<Spinner size='xs' borderWidth='1px' />
92+
<Span>Loading...</Span>
93+
</HStack>
94+
) : collection.items.length === 0 ? (
95+
<Combobox.Empty>No options found</Combobox.Empty>
96+
) : (
97+
collection.items.map(item => (
98+
<Combobox.Item key={item.value} item={item}>
99+
<HStack justify='space-between' textStyle='sm'>
100+
{item.label}
101+
</HStack>
102+
<Combobox.ItemIndicator />
103+
</Combobox.Item>
104+
))
105+
)}
106+
</Combobox.Content>
107+
</Combobox.Positioner>
108+
</Portal>
109+
</Combobox.Root>
110+
</Skeleton>
111+
);
112+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { useEffect, useMemo } from 'react';
2+
import { ComboboxSelect, type ComboboxProps } from './ComboboxSelect';
3+
import { useQuery } from '@tanstack/react-query';
4+
import { StatusService } from '@/api/services/status.service';
5+
6+
interface EndpointSelectProps extends Omit<ComboboxProps, 'items'> {
7+
defaultToFirst?: boolean;
8+
setName?: (name: string) => void;
9+
}
10+
11+
export function EndpointSelect({
12+
selectedValue,
13+
onChange,
14+
placeholder = 'Select endpoint...',
15+
setName,
16+
defaultToFirst = false,
17+
...rest
18+
}: EndpointSelectProps) {
19+
// Live endpoints
20+
const { data: liveData, isLoading } = useQuery({
21+
queryKey: ['live-status'],
22+
queryFn: () => StatusService.getLiveStatus({ pageSize: 100 }),
23+
staleTime: 30000,
24+
});
25+
26+
const selectedEndpointName = useMemo(() => {
27+
return (
28+
liveData?.items?.find(item => item.endpoint.id === selectedValue)?.endpoint?.name ||
29+
'Unknown Endpoint'
30+
);
31+
}, [liveData, selectedValue]);
32+
33+
useEffect(() => {
34+
if (setName) {
35+
setName(selectedEndpointName);
36+
}
37+
}, [selectedEndpointName, setName]);
38+
39+
return (
40+
<ComboboxSelect
41+
items={
42+
liveData?.items.map(item => ({
43+
label: item.endpoint.name,
44+
value: item.endpoint.id,
45+
})) || []
46+
}
47+
selectedValue={selectedValue}
48+
onChange={onChange}
49+
isLoading={isLoading}
50+
placeholder={placeholder}
51+
defaultToFirst={defaultToFirst}
52+
{...rest}
53+
/>
54+
);
55+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { Box } from '@chakra-ui/react';
2+
import type { LiveStatusItem } from '@/api/types';
3+
import { StatusTable } from './StatusTable';
4+
import { StatusGroupAccordion } from './StatusGroupAccordion';
5+
import { GroupAccordion } from './GroupAccordion';
6+
import { StatusAccordion } from './StatusAccordion';
7+
8+
type GroupedEndpointsType =
9+
| LiveStatusItem[]
10+
| Record<string, LiveStatusItem[]>
11+
| Record<string, Record<string, LiveStatusItem[]>>;
12+
13+
type EndpointAccordionProps = {
14+
groupedEndpoints: GroupedEndpointsType | null;
15+
isLoading: boolean;
16+
groupByOptions: string[];
17+
};
18+
19+
function isGroupedByStatusAndGroup(
20+
endpoints: any
21+
): endpoints is Record<string, Record<string, LiveStatusItem[]>> {
22+
return (
23+
typeof endpoints === 'object' &&
24+
!Array.isArray(endpoints) &&
25+
Object.values(endpoints).every(
26+
value =>
27+
typeof value === 'object' &&
28+
!Array.isArray(value) &&
29+
Object.values(value as any).every(Array.isArray)
30+
)
31+
);
32+
}
33+
34+
export function EndpointAccordion({
35+
groupedEndpoints,
36+
isLoading,
37+
groupByOptions,
38+
}: EndpointAccordionProps) {
39+
return (
40+
<>
41+
{groupedEndpoints !== null ? (
42+
Array.isArray(groupedEndpoints) ? (
43+
<Box pb={4}>
44+
<StatusTable items={groupedEndpoints} isLoading={isLoading} />
45+
</Box>
46+
) : isGroupedByStatusAndGroup(groupedEndpoints) ? (
47+
<StatusGroupAccordion groupedEndpoints={groupedEndpoints} isLoading={isLoading} />
48+
) : groupByOptions.includes('group') ? (
49+
<GroupAccordion groupedEndpoints={groupedEndpoints} isLoading={isLoading} />
50+
) : groupByOptions.includes('status') ? (
51+
<StatusAccordion groupedEndpoints={groupedEndpoints} isLoading={isLoading} />
52+
) : (
53+
<StatusTable items={Object.values(groupedEndpoints).flat()} isLoading={isLoading} />
54+
)
55+
) : (
56+
<StatusTable items={groupedEndpoints} isLoading={isLoading} />
57+
)}
58+
</>
59+
);
60+
}

0 commit comments

Comments
 (0)