|
| 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