useAsyncSelectOption.ts
132 lines
| 1 | import {useCallback, useEffect, useState} from 'react'; |
| 2 | import apiFetch from '@wordpress/api-fetch'; |
| 3 | import {UseAsyncSelectOptionReturn} from '@givewp/admin/types'; |
| 4 | |
| 5 | /** |
| 6 | * Custom hook for handling async option selection with pagination and search |
| 7 | * |
| 8 | * @since 4.11.0 |
| 9 | */ |
| 10 | export function useAsyncSelectOptions({ |
| 11 | recordId, |
| 12 | selectedOptionRecord, |
| 13 | endpoint, |
| 14 | recordsFormatter = (records: any) => records, |
| 15 | optionFormatter, |
| 16 | queryParams, |
| 17 | perPage = 30, |
| 18 | resetOnChange = false, |
| 19 | }: AsyncSelectOptionsConfig): UseAsyncSelectOptionReturn { |
| 20 | const [page, setPage] = useState(0); |
| 21 | const [selectedOption, setSelectedOption] = useState<Option | null>(null); |
| 22 | const [error, setError] = useState<Error | null>(null); |
| 23 | |
| 24 | // Reset page when reset property changes |
| 25 | useEffect(() => { |
| 26 | if (resetOnChange !== undefined) { |
| 27 | setPage(0); |
| 28 | } |
| 29 | }, [resetOnChange]); |
| 30 | |
| 31 | useEffect(() => { |
| 32 | if (selectedOptionRecord && recordId) { |
| 33 | setSelectedOption(optionFormatter(selectedOptionRecord)); |
| 34 | } else if (!recordId) { |
| 35 | setSelectedOption(null); |
| 36 | } |
| 37 | }, [selectedOptionRecord, recordId]); |
| 38 | |
| 39 | // Load options function for AsyncPaginate |
| 40 | const loadOptions = useCallback(async (searchInput: string) => { |
| 41 | const currentPage = searchInput ? 1 : page + 1; |
| 42 | |
| 43 | const params = new URLSearchParams({ |
| 44 | ...queryParams, |
| 45 | per_page: perPage.toString(), |
| 46 | page: currentPage.toString(), |
| 47 | ...(searchInput && {search: searchInput}), |
| 48 | }); |
| 49 | |
| 50 | setError(null); |
| 51 | |
| 52 | try { |
| 53 | const records = recordsFormatter(await apiFetch<[]>({ |
| 54 | path: `${endpoint}?${params.toString()}`, |
| 55 | })); |
| 56 | |
| 57 | const newOptions = (records || []).map(optionFormatter); |
| 58 | |
| 59 | // Update page state |
| 60 | if (searchInput !== '') { |
| 61 | setPage(1); |
| 62 | } else if (!searchInput) { |
| 63 | setPage(currentPage); |
| 64 | } |
| 65 | |
| 66 | const hasMoreResults = (records?.length || 0) >= perPage; |
| 67 | |
| 68 | return { |
| 69 | options: newOptions, |
| 70 | hasMore: hasMoreResults, |
| 71 | }; |
| 72 | } catch (err) { |
| 73 | const loadError = err instanceof Error ? err : new Error(`Failed to load options`); |
| 74 | setError(loadError); |
| 75 | console.error(`Failed to load options`, loadError); |
| 76 | |
| 77 | return { |
| 78 | options: [], |
| 79 | hasMore: false, |
| 80 | }; |
| 81 | } |
| 82 | }, [page, JSON.stringify(queryParams)]); |
| 83 | |
| 84 | // Map options for menu (deduplication and ordering) |
| 85 | const mapOptionsForMenu = useCallback( |
| 86 | (options: Option[]) => filterOptionsForSelect(options, selectedOption), |
| 87 | [selectedOption], |
| 88 | ); |
| 89 | |
| 90 | return { |
| 91 | selectedOption, |
| 92 | loadOptions, |
| 93 | mapOptionsForMenu, |
| 94 | error, |
| 95 | }; |
| 96 | } |
| 97 | |
| 98 | export type Option = { |
| 99 | value: number; |
| 100 | label: string; |
| 101 | } |
| 102 | |
| 103 | export type AsyncSelectOptionsConfig = { |
| 104 | recordId: number | null; |
| 105 | selectedOptionRecord: any; |
| 106 | recordsFormatter?: (records: any) => any; |
| 107 | optionFormatter: (record: any) => Option; |
| 108 | endpoint: string; |
| 109 | queryParams: {}; |
| 110 | perPage?: number; |
| 111 | resetOnChange?: any; |
| 112 | } |
| 113 | |
| 114 | export function filterOptionsForSelect(options: Option[], selectedOption: Option | null): Option[] { |
| 115 | // Remove duplicates and sort alphabetically |
| 116 | const filteredOptions = options |
| 117 | .filter((option, index, self) => index === self.findIndex((t) => t.value === option.value)) |
| 118 | .sort((a, b) => a.label.localeCompare(b.label)); |
| 119 | |
| 120 | // If no selected option, return filtered list |
| 121 | if (!selectedOption) { |
| 122 | return filteredOptions; |
| 123 | } |
| 124 | |
| 125 | // Put selected option first, then other options (excluding the selected one) |
| 126 | return [ |
| 127 | selectedOption, |
| 128 | ...filteredOptions.filter(option => option.value !== selectedOption.value), |
| 129 | ]; |
| 130 | } |
| 131 | |
| 132 |