Skip to content

Commit cc35fe2

Browse files
NathanZlionCopilot
andauthored
feat: ButtonDropdown async loading and manual filtering (#5105)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent 00e5e24 commit cc35fe2

39 files changed

Lines changed: 3314 additions & 93 deletions
Lines changed: 346 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,346 @@
1+
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
import React, { useContext, useState } from 'react';
4+
5+
import ButtonDropdown, { ButtonDropdownProps } from '~components/button-dropdown';
6+
import Checkbox from '~components/checkbox';
7+
import FormField from '~components/form-field';
8+
import { NonCancelableEventHandler } from '~components/internal/events';
9+
import Select from '~components/select';
10+
import SpaceBetween from '~components/space-between';
11+
12+
import AppContext, { AppContextType } from '../app/app-context';
13+
import { SimplePage } from '../app/templates';
14+
import { useOptionsLoader } from '../common/options-loader';
15+
16+
type StatusType = ButtonDropdownProps.AsyncLoadingStatusType;
17+
18+
type Scenario = 'flat' | 'groups' | 'paginated' | 'per-group' | 'error';
19+
20+
type PageContext = React.Context<
21+
AppContextType<{
22+
scenario: Scenario;
23+
expandToViewport: boolean;
24+
flatStatus: StatusType;
25+
flatItems: string;
26+
groupAStatus: StatusType;
27+
groupAItems: string;
28+
groupBStatus: StatusType;
29+
groupBItems: string;
30+
}>
31+
>;
32+
33+
const SCENARIO_OPTIONS: { value: Scenario; label: string }[] = [
34+
{ value: 'flat', label: 'Flat list, configurable status' },
35+
{ value: 'groups', label: 'Expandable groups, configurable status' },
36+
{ value: 'paginated', label: 'Flat list, paginated with manual filtering' },
37+
{ value: 'per-group', label: 'Expandable groups, loaded on expand' },
38+
{ value: 'error', label: 'Error with recovery' },
39+
];
40+
41+
const STATUS_OPTIONS = [
42+
{ value: 'loading', label: 'loading' },
43+
{ value: 'error', label: 'error' },
44+
{ value: 'pending', label: 'pending' },
45+
{ value: 'finished', label: 'finished' },
46+
];
47+
48+
const ITEMS_OPTIONS = [
49+
{ value: 'none', label: 'No items' },
50+
{ value: 'some', label: '6 items' },
51+
{ value: 'all', label: '12 items' },
52+
];
53+
54+
const ALL_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({
55+
id: `action-${i + 1}`,
56+
text: `Action ${i + 1}`,
57+
secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined,
58+
}));
59+
60+
const GROUP_A_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({
61+
id: `group-a-sub-${i + 1}`,
62+
text: `Sub-action A${i + 1}`,
63+
}));
64+
65+
const GROUP_B_ITEMS: ButtonDropdownProps.Item[] = Array.from({ length: 12 }, (_, i) => ({
66+
id: `group-b-sub-${i + 1}`,
67+
text: `Sub-action B${i + 1}`,
68+
}));
69+
70+
const PRESET_COUNTS: Record<string, number> = { none: 0, some: 6, all: 12 };
71+
72+
function itemsFromPreset(preset: string, source: ButtonDropdownProps.Item[]): ButtonDropdownProps.Items {
73+
return source.slice(0, PRESET_COUNTS[preset] ?? 0);
74+
}
75+
76+
const flatSourceItems: ButtonDropdownProps.Item[] = Array.from({ length: 25 }, (_, i) => ({
77+
id: `flat-action-${i + 1}`,
78+
text: `Action ${i + 1}`,
79+
secondaryText: i % 3 === 0 ? `Description for action ${i + 1}` : undefined,
80+
}));
81+
82+
const groupSourceItems: Record<string, ButtonDropdownProps.Item[]> = {
83+
'group-files': Array.from({ length: 8 }, (_, i) => ({ id: `file-${i + 1}`, text: `File action ${i + 1}` })),
84+
};
85+
86+
// Long enough to inspect the loading state and for integration tests to assert on it.
87+
const FETCH_DELAY_MS = 5000;
88+
89+
function fetchGroupItems(groupId: string): Promise<ButtonDropdownProps.Item[]> {
90+
if (groupId === 'group-files') {
91+
return new Promise(resolve => setTimeout(() => resolve(groupSourceItems['group-files']), FETCH_DELAY_MS));
92+
}
93+
if (groupId === 'group-edit') {
94+
return new Promise((_, reject) => setTimeout(() => reject(new Error('Server error')), FETCH_DELAY_MS));
95+
}
96+
return new Promise(() => {});
97+
}
98+
99+
const groupTexts: ButtonDropdownProps.AsyncLoadingProps = {
100+
loadingText: (gid?: string) => `Loading ${gid ?? 'items'}...`,
101+
errorText: (gid?: string) => `Failed to load ${gid ?? 'items'}.`,
102+
recoveryText: 'Retry',
103+
errorIconAriaLabel: 'Error',
104+
finishedText: (gid?: string) => `End of ${gid ?? 'results'}`,
105+
empty: (gid?: string) => `No items in ${gid ?? 'group'}.`,
106+
};
107+
108+
export default function ButtonDropdownAsyncLoadingPage() {
109+
// Page configuration lives in the URL so that every scenario and status/items combination is directly
110+
// linkable and targetable by integration tests. Fetched results below stay in local state.
111+
const {
112+
urlParams: {
113+
scenario = 'flat',
114+
expandToViewport = false,
115+
flatStatus = 'loading',
116+
flatItems: flatItemsPreset = 'none',
117+
groupAStatus = 'loading',
118+
groupAItems: groupAPreset = 'none',
119+
groupBStatus = 'error',
120+
groupBItems: groupBPreset = 'none',
121+
},
122+
setUrlParams,
123+
} = useContext(AppContext as PageContext);
124+
const onItemClick = (e: CustomEvent<ButtonDropdownProps.ItemClickDetails>) => console.log('clicked', e.detail.id);
125+
const logLoadItems: NonCancelableEventHandler<ButtonDropdownProps.LoadItemsDetail> = ({ detail }) =>
126+
console.log('onLoadItems', detail);
127+
128+
// Scenario "paginated"
129+
const {
130+
items: paginatedItems,
131+
status: paginatedStatus,
132+
filteringText: paginatedFilteringText,
133+
fetchItems,
134+
} = useOptionsLoader<ButtonDropdownProps.Item>({ pageSize: 10, timeout: FETCH_DELAY_MS });
135+
136+
// Scenario "per-group"
137+
const [groupItems, setGroupItems] = useState<Record<string, ButtonDropdownProps.Item[]>>({});
138+
const [groupStatuses, setGroupStatuses] = useState<Record<string, StatusType>>({});
139+
140+
// Scenario "error"
141+
const [errorStatus, setErrorStatus] = useState<StatusType>('error');
142+
const [errorItems, setErrorItems] = useState<ButtonDropdownProps.Items>([]);
143+
144+
const statusSelect = (label: string, value: StatusType, onChange: (value: StatusType) => void) => (
145+
<FormField label={label}>
146+
<Select
147+
selectedOption={STATUS_OPTIONS.find(o => o.value === value) ?? null}
148+
onChange={e => onChange(e.detail.selectedOption.value as StatusType)}
149+
options={STATUS_OPTIONS}
150+
/>
151+
</FormField>
152+
);
153+
const itemsSelect = (label: string, value: string, onChange: (value: string) => void) => (
154+
<FormField label={label}>
155+
<Select
156+
selectedOption={ITEMS_OPTIONS.find(o => o.value === value) ?? null}
157+
onChange={e => onChange(e.detail.selectedOption.value!)}
158+
options={ITEMS_OPTIONS}
159+
/>
160+
</FormField>
161+
);
162+
163+
const commonProps = { expandToViewport, onItemClick };
164+
165+
let content: React.ReactNode = null;
166+
switch (scenario) {
167+
case 'flat':
168+
content = (
169+
<ButtonDropdown
170+
{...commonProps}
171+
items={itemsFromPreset(flatItemsPreset, ALL_ITEMS)}
172+
asyncLoadingProps={{
173+
statusType: flatStatus,
174+
loadingText: () => 'Loading actions...',
175+
errorText: () => 'Failed to load actions.',
176+
recoveryText: 'Retry',
177+
errorIconAriaLabel: 'Error',
178+
finishedText: () => 'End of results',
179+
empty: () => 'No actions found',
180+
}}
181+
onLoadItems={logLoadItems}
182+
>
183+
Actions
184+
</ButtonDropdown>
185+
);
186+
break;
187+
case 'groups':
188+
content = (
189+
<ButtonDropdown
190+
{...commonProps}
191+
items={
192+
[
193+
{ id: 'group-a', text: 'Group A', items: itemsFromPreset(groupAPreset, GROUP_A_ITEMS) },
194+
{ id: 'group-b', text: 'Group B', items: itemsFromPreset(groupBPreset, GROUP_B_ITEMS) },
195+
] as ButtonDropdownProps.Items
196+
}
197+
expandableGroups={true}
198+
asyncLoadingProps={groupTexts}
199+
getExpandableItemsAsyncLoadingState={({ item }) => {
200+
if (item.id === 'group-a') {
201+
return groupAStatus;
202+
}
203+
if (item.id === 'group-b') {
204+
return groupBStatus;
205+
}
206+
return null;
207+
}}
208+
onLoadItems={logLoadItems}
209+
>
210+
Instance actions
211+
</ButtonDropdown>
212+
);
213+
break;
214+
case 'paginated':
215+
content = (
216+
<ButtonDropdown
217+
{...commonProps}
218+
items={paginatedItems as ButtonDropdownProps.Items}
219+
filteringType="manual"
220+
filteringPlaceholder="Filter actions"
221+
asyncLoadingProps={{
222+
statusType: paginatedStatus,
223+
loadingText: () => 'Loading actions...',
224+
errorText: () => 'Error fetching actions.',
225+
recoveryText: 'Retry',
226+
finishedText: () =>
227+
paginatedFilteringText ? `End of "${paginatedFilteringText}" results` : 'End of all results',
228+
empty: () => 'No actions found',
229+
}}
230+
filteringResultsText={(matchesCount, totalCount) =>
231+
paginatedStatus === 'pending' ? `${matchesCount}+ results` : `${matchesCount} of ${totalCount}`
232+
}
233+
onLoadItems={({ detail: { firstPage, filteringText } }) => {
234+
const normalized = filteringText.toLowerCase();
235+
const filtered = flatSourceItems.filter(item => (item.text ?? '').toLowerCase().includes(normalized));
236+
fetchItems({ firstPage, filteringText, sourceItems: filtered });
237+
}}
238+
>
239+
Async actions
240+
</ButtonDropdown>
241+
);
242+
break;
243+
case 'per-group':
244+
content = (
245+
<ButtonDropdown
246+
{...commonProps}
247+
items={
248+
[
249+
{ id: 'group-files', text: 'File (loads successfully)', items: groupItems['group-files'] ?? [] },
250+
{ id: 'group-edit', text: 'Edit (always errors)', items: groupItems['group-edit'] ?? [] },
251+
{ id: 'group-view', text: 'View (loading forever)', items: groupItems['group-view'] ?? [] },
252+
] as ButtonDropdownProps.Items
253+
}
254+
expandableGroups={true}
255+
asyncLoadingProps={groupTexts}
256+
getExpandableItemsAsyncLoadingState={({ item }) => (item.id ? (groupStatuses[item.id] ?? null) : null)}
257+
onLoadItems={({ detail: { expandedGroupId, samePage } }) => {
258+
if (!expandedGroupId) {
259+
return;
260+
}
261+
if (!samePage) {
262+
setGroupItems(prev => ({ ...prev, [expandedGroupId]: [] }));
263+
}
264+
setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'loading' }));
265+
fetchGroupItems(expandedGroupId)
266+
.then(items => {
267+
setGroupItems(prev => ({ ...prev, [expandedGroupId]: items }));
268+
setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'finished' }));
269+
})
270+
.catch(() => {
271+
setGroupStatuses(prev => ({ ...prev, [expandedGroupId]: 'error' }));
272+
});
273+
}}
274+
>
275+
Instance actions
276+
</ButtonDropdown>
277+
);
278+
break;
279+
case 'error':
280+
content = (
281+
<ButtonDropdown
282+
{...commonProps}
283+
items={errorItems}
284+
asyncLoadingProps={{
285+
statusType: errorStatus,
286+
loadingText: () => 'Loading actions...',
287+
errorText: () => 'Error fetching actions.',
288+
recoveryText: 'Retry',
289+
errorIconAriaLabel: 'Error',
290+
empty: () => 'No actions found',
291+
}}
292+
onLoadItems={({ detail: { samePage } }) => {
293+
if (samePage) {
294+
setErrorStatus('loading');
295+
setTimeout(() => {
296+
setErrorItems(flatSourceItems.slice(0, 8));
297+
setErrorStatus('finished');
298+
}, FETCH_DELAY_MS);
299+
} else {
300+
setErrorItems([]);
301+
setErrorStatus('error');
302+
}
303+
}}
304+
>
305+
Actions (error)
306+
</ButtonDropdown>
307+
);
308+
break;
309+
}
310+
311+
return (
312+
<SimplePage
313+
title="ButtonDropdown async loading"
314+
settings={
315+
<SpaceBetween size="s" direction="horizontal">
316+
<FormField label="Scenario">
317+
<Select
318+
selectedOption={SCENARIO_OPTIONS.find(o => o.value === scenario) ?? null}
319+
onChange={e => setUrlParams({ scenario: e.detail.selectedOption.value as Scenario })}
320+
options={SCENARIO_OPTIONS}
321+
/>
322+
</FormField>
323+
{scenario === 'flat' && (
324+
<>
325+
{statusSelect('statusType', flatStatus, value => setUrlParams({ flatStatus: value }))}
326+
{itemsSelect('items', flatItemsPreset, value => setUrlParams({ flatItems: value }))}
327+
</>
328+
)}
329+
{scenario === 'groups' && (
330+
<>
331+
{statusSelect('Group A - statusType', groupAStatus, value => setUrlParams({ groupAStatus: value }))}
332+
{itemsSelect('Group A - items', groupAPreset, value => setUrlParams({ groupAItems: value }))}
333+
{statusSelect('Group B - statusType', groupBStatus, value => setUrlParams({ groupBStatus: value }))}
334+
{itemsSelect('Group B - items', groupBPreset, value => setUrlParams({ groupBItems: value }))}
335+
</>
336+
)}
337+
<Checkbox checked={expandToViewport} onChange={e => setUrlParams({ expandToViewport: e.detail.checked })}>
338+
Expand to viewport
339+
</Checkbox>
340+
</SpaceBetween>
341+
}
342+
>
343+
{content}
344+
</SimplePage>
345+
);
346+
}

0 commit comments

Comments
 (0)