From 4356e0521ba3ea48fda57d66649a23625ed1dae0 Mon Sep 17 00:00:00 2001 From: Azeezat Date: Sun, 8 Sep 2024 21:25:12 -0700 Subject: [PATCH 01/13] chore: refactored code to use hooks --- .github/workflows/coverage.yml | 19 + .gitignore | 5 +- README.md | 2 + babel.config.js | 19 +- example/.eslintrc.js | 2 +- example/src/App.tsx | 57 ++- jest-setup.ts | 2 +- package.json | 26 +- src/__tests__/empty-dropdown.test.tsx | 75 +++- src/__tests__/flat-list-dropdown.test.tsx | 112 ++++- src/__tests__/section-list-dropdown.test.tsx | 39 +- src/components/CheckBox/index.tsx | 10 +- src/components/CustomModal/index.tsx | 1 + .../Dropdown/DropdownSelectedItemsView.tsx | 2 + src/components/Input/index.tsx | 15 +- src/hooks/index.ts | 5 + src/hooks/use-index-of-selected-item.ts | 49 +++ src/hooks/use-modal.ts | 51 +++ src/hooks/use-search.ts | 91 ++++ src/hooks/use-select-all.ts | 79 ++++ src/hooks/use-selection-handler.ts | 81 ++++ src/index.tsx | 384 ++++++----------- src/types/index.types.ts | 8 +- src/utils/index.ts | 63 ++- yarn.lock | 391 +++++++++++------- 25 files changed, 1126 insertions(+), 462 deletions(-) create mode 100644 .github/workflows/coverage.yml create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/use-index-of-selected-item.ts create mode 100644 src/hooks/use-modal.ts create mode 100644 src/hooks/use-search.ts create mode 100644 src/hooks/use-select-all.ts create mode 100644 src/hooks/use-selection-handler.ts diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 0000000..6136fff --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,19 @@ +name: Code Coverage Summary Report +on: + push: + branches: [ "main" ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ "main" ] +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + steps: + - name: Code coverage report + uses: irongut/CodeCoverageSummary@v1.3.0 + with: + filename: coverage.cobertura.xml \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7a4c932..1d45122 100644 --- a/.gitignore +++ b/.gitignore @@ -64,4 +64,7 @@ android/keystores/debug.keystore lib/ # npm -.npmrc \ No newline at end of file +.npmrc + +# coverage +coverage \ No newline at end of file diff --git a/README.md b/README.md index 53a46c0..0ca35c2 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,8 @@ For more examples visit our [wiki page](https://github.com/azeezat/react-native- | listControls | `Object` | `{ selectAllText: 'Choose all', unselectAllText: 'Remove all', selectAllCallback: () => {}, unselectAllCallback: () => {}, hideSelectAll: boolean, emptyListMessage: 'No record found'}` | | searchControls | `Object` | `{ textInputStyle: ViewStyle \| TextStyle, textInputContainerStyle: ViewStyle, textInputProps: TextInputProps, searchCallback:(value)=>{}}` | | modalControls | `Object` | `{ modalBackgroundStyle: ViewStyle, modalOptionsContainerStyle: ViewStyle, modalProps: ModalProps}` | +| maxSelectableItems | `number` | 5 | + ## Deprecation Notice diff --git a/babel.config.js b/babel.config.js index 0106e72..98b2c74 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,10 +1,15 @@ module.exports = { presets: ['module:metro-react-native-babel-preset'], - overrides: [{ - "plugins": [ - ["@babel/plugin-transform-private-methods", { - "loose": true - }] - ] - }] + overrides: [ + { + plugins: [ + [ + '@babel/plugin-transform-private-methods', + { + loose: true, + }, + ], + ], + }, + ], }; diff --git a/example/.eslintrc.js b/example/.eslintrc.js index 1030be2..840a722 100644 --- a/example/.eslintrc.js +++ b/example/.eslintrc.js @@ -1,4 +1,4 @@ module.exports = { root: true, - extends: ['@react-native', "@babel/plugin-transform-private-property-in-object"], + extends: ['@react-native'], }; diff --git a/example/src/App.tsx b/example/src/App.tsx index e76f66c..053bac8 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -17,7 +17,7 @@ import {countries} from './data'; export default function App() { const [user, setUser] = useState(''); const [country, setCountry] = useState(''); - const [gender, setGender] = useState(); + const [gender, setGender] = useState(0); const [currency, setCurrency] = useState([]); const [meals, setMeals] = useState([]); const [item, setItem] = useState(''); @@ -45,6 +45,7 @@ export default function App() { + + + Male + + ), + id: 1, + }, + { + name: ( + + + + Female + + ), + id: 2, + }, ]} optionLabel={'name'} optionValue={'id'} @@ -143,15 +172,20 @@ export default function App() { label="Meal preferences" placeholder="Select your meal preferences" options={[ - {name: '🍛 Rice', value: '1', disabled: true}, + {name: '🍛 Rice', value: '1', disabled: false}, {name: '🍗 Chicken', value: '2'}, - {name: '🥦 Brocoli', value: '3', disabled: true}, + {name: '🥦 Brocoli', value: '3', disabled: false}, {name: '🍕 Pizza', value: '4'}, ]} + maxSelectableItems={2} optionLabel={'name'} optionValue={'value'} selectedValue={meals} - onValueChange={(itemValue: any) => setMeals(itemValue)} + onValueChange={(itemValue: any) => { + meals.length === 2 && console.log('You can only select 2 meals'); + + setMeals(itemValue); + }} dropdownStyle={{ backgroundColor: 'yellow', paddingVertical: 5, @@ -467,4 +501,15 @@ const styles = StyleSheet.create({ borderWidth: 3, borderColor: 'white', }, + avatarStyle: { + height: 20, + width: 20, + borderRadius: 20, + marginRight: 5, + }, + itemStyle: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + }, }); diff --git a/jest-setup.ts b/jest-setup.ts index 0b4c5a3..1d3ff30 100644 --- a/jest-setup.ts +++ b/jest-setup.ts @@ -1 +1 @@ -import '@testing-library/react-native/extend-expect'; \ No newline at end of file +import '@testing-library/react-native/extend-expect'; diff --git a/package.json b/package.json index 03a48e6..17ead01 100644 --- a/package.json +++ b/package.json @@ -68,14 +68,14 @@ "@types/react": "18.3.2", "@types/react-native": "^0.73.0", "commitlint": "^19.3.0", - "eslint": "^9.2.0", - "eslint-config-prettier": "^9.1.0", - "eslint-plugin-prettier": "^5.1.3", + "eslint": "^7.2.0", + "eslint-config-prettier": "^7.0.0", + "eslint-plugin-prettier": "^3.1.3", "husky": "^9.0.11", "jest": "^29.7.0", "metro-react-native-babel-preset": "^0.77.0", "pod-install": "^0.2.2", - "prettier": "^3.2.5", + "prettier": "^2.0.5", "react": "^18.3.1", "react-native": "^0.74.1", "react-native-builder-bob": "^0.23.2", @@ -92,7 +92,19 @@ "modulePathIgnorePatterns": [ "/example/node_modules", "/lib/" - ] + ], + "coverageReporters": [ + "html", + "text" + ], + "coverageThreshold": { + "global": { + "branches": 75, + "functions": 75, + "lines": 75, + "statements": 75 + } + } }, "commitlint": { "extends": [ @@ -120,7 +132,9 @@ }, "eslintIgnore": [ "node_modules/", - "lib/" + "lib/", + "coverage/", + "src/__tests__/**" ], "prettier": { "quoteProps": "consistent", diff --git a/src/__tests__/empty-dropdown.test.tsx b/src/__tests__/empty-dropdown.test.tsx index 848f020..41c8fe6 100644 --- a/src/__tests__/empty-dropdown.test.tsx +++ b/src/__tests__/empty-dropdown.test.tsx @@ -11,29 +11,92 @@ describe('Initial state of component', () => { afterAll(() => { jest.useRealTimers(); }); + + // TODO: test these mocks once you are able to simulate specific device types like android and iOS + const mockOpenModal = jest.fn(); + const mockCloseModal = jest.fn(); + + const testId = 'some-random-test-id'; + const placeholder = 'Select an option'; + const error = 'This is an error'; + const helperText = 'This is an helper text'; + const defaultDropdown = ( - {}} testID='some-random-test-id' /> + {}} + testID={testId} + modalControls={{ + modalProps: { + onShow: mockOpenModal, + onDismiss: mockCloseModal, + }, + }} + error={error} + /> ); test('show default texts', () => { render(defaultDropdown); - expect(screen.getByTestId('some-random-test-id')); - expect(screen.getByText('Select an option')); + const entireDropdown = screen.getByTestId(testId); + expect(entireDropdown); + expect(screen.getByText(placeholder)); + expect(screen.getByText(error)); }); test('show default styles', () => { render(defaultDropdown); - const placeholderStyle = screen.getByText('Select an option'); + const placeholderStyle = screen.getByText(placeholder); expect(placeholderStyle.props.style).toMatchObject([ { color: '#000000' }, undefined, ]); }); - test('open modal when dropdown is clicked', async () => { + test('open and close modal', async () => { const user = userEvent.setup(); render(defaultDropdown); - await user.press(screen.getByText('Select an option')); + + //open modal when dropdown is clicked + await user.press(screen.getByText(placeholder)); expect(screen.getByText('No options available')); + + // close the modal after opening + const closeModal = screen.getByLabelText('close modal'); + await user.press(closeModal); + expect(screen.getByText(placeholder)); + }); + + const disabledDropdown = ( + {}} + modalControls={{ + modalProps: { + onShow: mockOpenModal, + onDismiss: mockCloseModal, + }, + }} + disabled + helperText={helperText} + /> + ); + + test('helper text', async () => { + render(disabledDropdown); + expect(screen.getByText(helperText)); + }); + + test('Disabled dropdown should not be clickable', async () => { + const user = userEvent.setup(); + render(disabledDropdown); + + let dropdownInput = screen.getByTestId('dropdown-input-container'); + await user.press(dropdownInput); + + expect(dropdownInput.props?.accessibilityState?.disabled).toBe(true); }); }); diff --git a/src/__tests__/flat-list-dropdown.test.tsx b/src/__tests__/flat-list-dropdown.test.tsx index 523e980..1046a84 100644 --- a/src/__tests__/flat-list-dropdown.test.tsx +++ b/src/__tests__/flat-list-dropdown.test.tsx @@ -21,37 +21,129 @@ describe('Initial state of component', () => { { name: '🍕 Pizza', value: '4' }, ]; + const mockOnValueChange = jest.fn(); + const placeholder = 'Select food'; + const testId = 'section-list-test-id'; + const mockSearchCallback = jest.fn(); + const flatListDropdown = ( {}} - testID="section-list-test-id" - placeholder="Select food" + onValueChange={mockOnValueChange} + testID={testId} + placeholder={placeholder} optionLabel="name" optionValue="value" + isSearchable + searchControls={{ + textInputProps: { placeholder: 'Search anything here' }, + searchCallback: mockSearchCallback, + }} /> ); test('show default texts', () => { render(flatListDropdown); - expect(screen.getByTestId('section-list-test-id')); - expect(screen.getByText('Select food')); + expect(screen.getByTestId(testId)); + expect(screen.getByText(placeholder)); }); test('show default styles', () => { render(flatListDropdown); - const placeholderStyle = screen.getByText('Select food'); + const placeholderStyle = screen.getByText(placeholder); expect(placeholderStyle.props.style).toMatchObject([ { color: '#000000' }, undefined, ]); }); - test('open modal when dropdown is clicked', async () => { + test('search', async () => { const user = userEvent.setup(); render(flatListDropdown); - await user.press(screen.getByText('Select food')); - expect(screen.getByText(options[0].name as string)); - expect(screen.getByText('Chicken', { exact: false })); + + //open modal + await user.press(screen.getByText(placeholder)); + + let totalCount = 0; + + //search non-existent item + const searchPlaceholder = 'Search anything here'; + const searchBox = screen.getByPlaceholderText(searchPlaceholder); + let text = 'hello'; + totalCount += text.length; + await user.type(searchBox, text); + screen.getByText('No options available'); + expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount); + + //search existent item + text = 'rice'; + totalCount += text.length; + await user.clear(searchBox); + await user.type(searchBox, text); + screen.getByText(text, { exact: false }); + expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount + 1); //adding 1 because the clear event also called the search callback + }); + + describe('Single select', () => { + test('open modal when dropdown is clicked and select a single item', async () => { + const user = userEvent.setup(); + render(flatListDropdown); + await user.press(screen.getByText(placeholder)); + expect(screen.getByText(options[0].name as string)); + const optionToTestFor = screen.getByText('Chicken', { exact: false }); + + expect(optionToTestFor); + + //select one option + await user.press(optionToTestFor); + expect(mockOnValueChange).toHaveBeenCalledTimes(1); + }); + }); + + describe('Multiple select', () => { + let mockOnValueChangeMultiSelect = jest.fn(); + + const flatListDropdownWithMultiSelect = ( + + ); + + test('open modal when dropdown is clicked and select a multiple items', async () => { + const user = userEvent.setup(); + render(flatListDropdownWithMultiSelect); + await user.press(screen.getByText(placeholder)); + + // select multiple options + const firstSelection = screen.getByText(options[0].name as string); // This value has been disabled, so no call is expected + expect(firstSelection); + await user.press(firstSelection); + + const secondSelection = screen.getByText('Chicken', { exact: false }); + expect(secondSelection); + await user.press(secondSelection); + + const thirdSelection = screen.getByText(options[2].name as string); // This value has been disabled, so no call is expected + expect(thirdSelection); + await user.press(thirdSelection); + + const forthSelection = screen.getByText(options[3].name as string); + expect(thirdSelection); + await user.press(forthSelection); + + expect(mockOnValueChangeMultiSelect).toHaveBeenCalledTimes(2); + + //`Clear All` should now be visible since all items in the list have been selected + screen.getByText('Clear all'); + }); }); }); diff --git a/src/__tests__/section-list-dropdown.test.tsx b/src/__tests__/section-list-dropdown.test.tsx index ee3f96c..d8b40f4 100644 --- a/src/__tests__/section-list-dropdown.test.tsx +++ b/src/__tests__/section-list-dropdown.test.tsx @@ -13,7 +13,10 @@ describe('Initial state of component', () => { jest.useRealTimers(); }); - const options:TSectionList = [ + const mockSearchCallback = jest.fn(); + const placeholder = 'Select an option'; + + const options: TSectionList = [ { title: 'Main dishes', data: [ @@ -41,9 +44,15 @@ describe('Initial state of component', () => { const sectionListDropdown = ( {}} testID="section-list-test-id" + isSearchable + searchControls={{ + textInputProps: { placeholder: 'Search anything here' }, + searchCallback: mockSearchCallback, + }} /> ); @@ -62,10 +71,36 @@ describe('Initial state of component', () => { ]); }); + test('search', async () => { + const user = userEvent.setup(); + render(sectionListDropdown); + + //open modal + await user.press(screen.getByText(placeholder)); + + let totalCount = 0; + + //search non-existent item + const searchPlaceholder = 'Search anything here'; + const searchBox = screen.getByPlaceholderText(searchPlaceholder); + let text = 'hello'; + totalCount += text.length; + await user.type(searchBox, text); + expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount); + + //search existent item + text = 'pizza'; + totalCount += text.length; + await user.clear(searchBox); + await user.type(searchBox, text); + screen.getByText(text, { exact: false }); + expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount + 1); //adding 1 because the clear event also called the search callback + }); + test('open modal when dropdown is clicked', async () => { const user = userEvent.setup(); render(sectionListDropdown); - await user.press(screen.getByText('Select an option')); + await user.press(screen.getByText(placeholder)); expect(screen.getByText(options[0].title)); }); }); diff --git a/src/components/CheckBox/index.tsx b/src/components/CheckBox/index.tsx index 09e9967..9aec008 100644 --- a/src/components/CheckBox/index.tsx +++ b/src/components/CheckBox/index.tsx @@ -22,11 +22,11 @@ const CheckBox = ({ ? checkboxControls?.checkboxDisabledStyle?.backgroundColor || colors.disabled : value - ? checkboxControls?.checkboxStyle?.backgroundColor || - checkboxComponentStyles?.checkboxStyle?.backgroundColor || - checkboxStyle?.backgroundColor || - primaryColor - : checkboxControls?.checkboxUnselectedColor || 'white', + ? checkboxControls?.checkboxStyle?.backgroundColor || + checkboxComponentStyles?.checkboxStyle?.backgroundColor || + checkboxStyle?.backgroundColor || + primaryColor + : checkboxControls?.checkboxUnselectedColor || 'white', borderColor: disabled ? checkboxControls?.checkboxDisabledStyle?.borderColor || colors.disabled : checkboxControls?.checkboxStyle?.borderColor || diff --git a/src/components/CustomModal/index.tsx b/src/components/CustomModal/index.tsx index 5a97709..ff56482 100644 --- a/src/components/CustomModal/index.tsx +++ b/src/components/CustomModal/index.tsx @@ -59,6 +59,7 @@ const CustomModal = ({ styles.modalBackgroundStyle, modalControls?.modalBackgroundStyle || modalBackgroundStyle, ]} + aria-label="close modal" > {/* Added this `TouchableWithoutFeedback` wrapper because of the closing modal on expo web */} {}}> diff --git a/src/components/Dropdown/DropdownSelectedItemsView.tsx b/src/components/Dropdown/DropdownSelectedItemsView.tsx index 4cf7448..2372d3f 100644 --- a/src/components/Dropdown/DropdownSelectedItemsView.tsx +++ b/src/components/Dropdown/DropdownSelectedItemsView.tsx @@ -47,6 +47,8 @@ const DropdownSelectedItemsView = ({ }, ]} disabled={disabled} + aria-disabled={disabled} + testID="dropdown-input-container" > { +}: { + primaryColor?: ColorValue; + textInputContainerStyle?: ViewStyle; +} & TextInputProps) => { const [isFocused, setFocus] = useState(false); return ( diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..8b881e8 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,5 @@ +export * from './use-search'; +export * from './use-selection-handler'; +export * from './use-index-of-selected-item'; +export * from './use-modal'; +export * from './use-select-all'; diff --git a/src/hooks/use-index-of-selected-item.ts b/src/hooks/use-index-of-selected-item.ts new file mode 100644 index 0000000..4b79da8 --- /dev/null +++ b/src/hooks/use-index-of-selected-item.ts @@ -0,0 +1,49 @@ +import { useState, useCallback } from 'react'; +import type { TFlatListItem, TSectionListItem } from '../types/index.types'; + +interface UseIndexOfSelectedItemProps { + options: (TFlatListItem | TSectionListItem)[]; + optionLabel: string; + isSectionList: boolean; +} + +/** + * + * @description for scrollToIndex in Sectionlist and Flatlist + */ + +export const useIndexOfSelectedItem = ({ + options, + optionLabel, + isSectionList, +}: UseIndexOfSelectedItemProps) => { + const [listIndex, setListIndex] = useState<{ + sectionIndex?: number; + itemIndex: number; + }>({ itemIndex: -1, sectionIndex: -1 }); + + const setIndexOfSelectedItem = useCallback( + (selectedLabel: string) => { + if (isSectionList) { + (options as TSectionListItem[]).forEach((section, sectionIndex) => { + const itemIndex = section.data.findIndex( + (item) => item[optionLabel] === selectedLabel + ); + if (itemIndex !== -1) { + setListIndex({ sectionIndex, itemIndex }); + } + }); + } else { + const itemIndex = (options as TFlatListItem[]).findIndex( + (item) => item[optionLabel] === selectedLabel + ); + if (itemIndex !== -1) { + setListIndex({ itemIndex }); + } + } + }, + [options, optionLabel, isSectionList] + ); + + return { listIndex, setListIndex, setIndexOfSelectedItem }; +}; diff --git a/src/hooks/use-modal.ts b/src/hooks/use-modal.ts new file mode 100644 index 0000000..bcb421c --- /dev/null +++ b/src/hooks/use-modal.ts @@ -0,0 +1,51 @@ +import { useState, useCallback, useEffect } from 'react'; +import { ModalProps, Platform } from 'react-native'; + +interface UseModalProps { + hideModal: boolean; + modalProps?: ModalProps; + onDismiss?: () => void; + resetOptionsRelatedState: () => void; + disabled?: boolean; +} + +export const useModal = ({ + hideModal, + onDismiss, + resetOptionsRelatedState, + disabled, +}: UseModalProps) => { + const [open, setOpen] = useState(false); + + useEffect(() => { + if (hideModal) { + setOpen(false); + } + }, [hideModal]); + + useEffect(() => { + if (!open && Platform.OS === 'android') { + onDismiss?.(); + } + }, [open, onDismiss]); + + const openModal = useCallback(() => { + if (disabled) { + return; + } + setOpen(true); + resetOptionsRelatedState(); + }, [disabled, setOpen, resetOptionsRelatedState]); + + const closeModal = useCallback(() => { + setOpen(false); + resetOptionsRelatedState(); + }, [setOpen, resetOptionsRelatedState]); + + return { + open, + setOpen, + openModal, + closeModal, + }; +}; diff --git a/src/hooks/use-search.ts b/src/hooks/use-search.ts new file mode 100644 index 0000000..49e5e33 --- /dev/null +++ b/src/hooks/use-search.ts @@ -0,0 +1,91 @@ +import { useState, useCallback, useEffect } from 'react'; +import type { + TFlatList, + TSectionList, + TFlatListItem, + TSectionListItem, +} from '../types/index.types'; +import { escapeRegExp, isSectionList } from '../utils'; + +interface UseSearchProps { + initialOptions: TFlatList | TSectionList; + optionLabel: string; + optionValue: string; + searchCallback: (value: string) => void; +} + +export const useSearch = ({ + initialOptions, + optionLabel, + optionValue, + searchCallback, +}: UseSearchProps) => { + const [searchValue, setSearchValue] = useState(''); + const [filteredOptions, setFilteredOptions] = useState< + TFlatList | TSectionList + >(initialOptions); + + useEffect(() => { + setFilteredOptions(initialOptions); + return () => {}; + }, [initialOptions]); + + const searchFlatList = useCallback( + (flatList: TFlatList, regexFilter: RegExp) => { + return flatList.filter((item: TFlatListItem) => { + return ( + item[optionLabel].toString().toLowerCase().search(regexFilter) !== + -1 || + item[optionValue].toString().toLowerCase().search(regexFilter) !== -1 + ); + }); + }, + [optionLabel, optionValue] + ); + + const searchSectionList = useCallback( + (sectionList: TSectionList, regexFilter: RegExp) => { + return sectionList.map((listItem: TSectionListItem) => { + // A section list is the combination of several flat lists + const filteredData = searchFlatList(listItem.data, regexFilter); + + return { ...listItem, data: filteredData }; + }); + }, + [searchFlatList] + ); + + const isSection = isSectionList(initialOptions); + + const onSearch = useCallback( + (value: string) => { + setSearchValue(value); + searchCallback?.(value); + + const searchText = escapeRegExp(value).toLowerCase().trim(); + const regexFilter = new RegExp(searchText, 'i'); + + const searchResults = isSection + ? searchSectionList(initialOptions as TSectionList, regexFilter) + : searchFlatList(initialOptions as TFlatList, regexFilter); + + setFilteredOptions(searchResults); + }, + [ + initialOptions, + isSection, + searchCallback, + searchFlatList, + searchSectionList, + ] + ); + + return { + searchValue, + setSearchValue, + filteredOptions, + setFilteredOptions, + onSearch, + isSectionList: isSection, + }; +}; diff --git a/src/hooks/use-select-all.ts b/src/hooks/use-select-all.ts new file mode 100644 index 0000000..101ccf6 --- /dev/null +++ b/src/hooks/use-select-all.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useState } from 'react'; +import { TFlatListItem, TSelectedItem } from '../types/index.types'; +import { removeDisabledItems } from '../utils'; + +interface UseCheckSelectAllProps { + options: TFlatListItem[]; + selectedItems: TSelectedItem[]; + isMultiple: boolean; + onValueChange: (selectedValues: TSelectedItem[]) => void; + listControls?: { + selectAllCallback?: () => void; + unselectAllCallback?: () => void; + }; + optionValue: string; +} + +export const useSelectAll = ({ + options, + selectedItems, + isMultiple, + onValueChange, + listControls, + optionValue, +}: UseCheckSelectAllProps) => { + const [selectAll, setSelectAll] = useState(false); + + /** + * @description Handle "Select All" logic + */ + const handleSelectAll = useCallback(() => { + setSelectAll((prevVal) => { + let selectedValues: TSelectedItem[] = []; + + // Remove disabled items from selection + const filteredOptions = removeDisabledItems(options); + + // if everything has not been selected, select all the values in the list + if (!prevVal) { + selectedValues = filteredOptions.map( + (obj) => obj[optionValue] + ) as TSelectedItem[]; + } + + onValueChange(selectedValues); // Send selected values to parent + return !prevVal; + }); + + if (typeof listControls?.selectAllCallback === 'function' && !selectAll) { + listControls.selectAllCallback(); + } + + if (typeof listControls?.unselectAllCallback === 'function' && selectAll) { + listControls.unselectAllCallback(); + } + }, [options, optionValue, listControls, selectAll, onValueChange]); + + /** + * Check if all items are selected + */ + const checkSelectAll = useCallback(() => { + if (removeDisabledItems(options)?.length === selectedItems?.length) { + setSelectAll(true); + } else { + setSelectAll(false); + } + }, [options, selectedItems]); + + /** + * if the user decides to select the options one by one, this hook + * runs to check if everything has been selected so that the `selectAll checkbox` can be selected + */ + useEffect(() => { + if (isMultiple) { + checkSelectAll(); + } + }, [checkSelectAll, isMultiple, selectedItems]); + + return { selectAll, handleSelectAll }; +}; diff --git a/src/hooks/use-selection-handler.ts b/src/hooks/use-selection-handler.ts new file mode 100644 index 0000000..317f8c5 --- /dev/null +++ b/src/hooks/use-selection-handler.ts @@ -0,0 +1,81 @@ +import { useState, useCallback } from 'react'; +import { TSelectedItem } from '../types/index.types'; + +interface UseSelectionHandlerProps { + initialSelectedValue: TSelectedItem | TSelectedItem[]; // Can be a single value or an array + isMultiple: boolean; + maxSelectableItems?: number; + onValueChange: (selectedItems: TSelectedItem | TSelectedItem[]) => void; + setOpen: (value: boolean) => void; + autoCloseOnSelect: boolean; +} + +export const useSelectionHandler = ({ + initialSelectedValue, + isMultiple, + maxSelectableItems, + onValueChange, + setOpen, + autoCloseOnSelect, +}: UseSelectionHandlerProps) => { + // Initialize state based on whether it's multiple selection or not + const [selectedItem, setSelectedItem] = useState( + isMultiple ? '' : (initialSelectedValue as TSelectedItem) + ); + const [selectedItems, setSelectedItems] = useState( + isMultiple ? (initialSelectedValue as TSelectedItem[]) : [] + ); + + const handleSingleSelection = useCallback( + (value: TSelectedItem) => { + if (selectedItem === value) { + setSelectedItem(''); + onValueChange(''); // Send null to parent when deselected + } else { + setSelectedItem(value); + onValueChange(value); // Send selected value to parent + + if (autoCloseOnSelect) { + setOpen(false); // close modal upon selection + } + } + }, + [selectedItem, onValueChange, autoCloseOnSelect, setOpen] + ); + + const handleMultipleSelections = useCallback( + (value: TSelectedItem) => { + setSelectedItems((prevVal) => { + let selectedValues = [...prevVal]; + + if (selectedValues.includes(value)) { + // Remove item + selectedValues = selectedValues.filter((item) => item !== value); + } else { + // Add item + if ( + maxSelectableItems && + selectedValues.length >= maxSelectableItems + ) { + return selectedValues; + } + selectedValues.push(value); + } + + onValueChange(selectedValues); // Send selected values to parent + return selectedValues; + }); + }, + [maxSelectableItems, onValueChange] + ); + + // Return the relevant state and handlers + return { + selectedItem, + selectedItems, + handleSingleSelection, + handleMultipleSelections, + setSelectedItems, // Expose for potential manual control + setSelectedItem, // Expose for potential manual control + }; +}; diff --git a/src/index.tsx b/src/index.tsx index f3eb421..0a0f8d3 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { TouchableOpacity, StyleSheet, View, Platform } from 'react-native'; import Input from './components/Input'; import CheckBox from './components/CheckBox'; @@ -8,15 +8,15 @@ import DropdownSectionList from './components/Dropdown/DropdownSectionList'; import CustomModal from './components/CustomModal'; import { colors } from './styles/colors'; import { DEFAULT_OPTION_LABEL, DEFAULT_OPTION_VALUE } from './constants'; -import type { - DropdownProps, - TFlatList, - TFlatListItem, - TSectionList, - TSectionListItem, - TSelectedItem, -} from './types/index.types'; -import { escapeRegExp, extractPropertyFromArray } from './utils'; +import type { DropdownProps, TSelectedItem } from './types/index.types'; +import { extractPropertyFromArray, getSelectedItemsLabel } from './utils'; +import { + useSelectionHandler, + useModal, + useSearch, + useIndexOfSelectedItem, + useSelectAll, +} from './hooks'; export const DropdownSelect: React.FC = ({ testID, @@ -28,8 +28,8 @@ export const DropdownSelect: React.FC = ({ optionLabel = DEFAULT_OPTION_LABEL, optionValue = DEFAULT_OPTION_VALUE, onValueChange, - selectedValue, - isMultiple, + isMultiple = false, + selectedValue = isMultiple ? [] : '', isSearchable, dropdownIcon, labelStyle, @@ -46,7 +46,7 @@ export const DropdownSelect: React.FC = ({ modalOptionsContainerStyle, // kept for backwards compatibility searchInputStyle, // kept for backwards compatibility primaryColor = colors.gray, - disabled, + disabled = false, checkboxSize, // kept for backwards compatibility checkboxStyle, // kept for backwards compatibility checkboxLabelStyle, // kept for backwards compatibility @@ -63,222 +63,65 @@ export const DropdownSelect: React.FC = ({ modalControls, checkboxControls, autoCloseOnSelect = true, + maxSelectableItems, ...rest }) => { - const [newOptions, setNewOptions] = useState([]); - const [open, setOpen] = useState(false); - const [selectAll, setSelectAll] = useState(false); - const [selectedItem, setSelectedItem] = useState(''); // for single selection - const [selectedItems, setSelectedItems] = useState([]); // for multiple selection - const [searchValue, setSearchValue] = useState(''); - const [listIndex, setListIndex] = useState<{ - sectionIndex?: number; - itemIndex: number; - }>({ itemIndex: -1, sectionIndex: -1 }); // for scrollToIndex in Sectionlist and Flatlist - - useEffect(() => { - setNewOptions(options); - return () => {}; - }, [options]); - - useEffect(() => { - isMultiple - ? setSelectedItems(Array.isArray(selectedValue) ? selectedValue : []) - : setSelectedItem((selectedValue as TSelectedItem) || ''); - - return () => {}; - }, [selectedValue, isMultiple, onValueChange]); - /*=========================================== - * List type + * Search *==========================================*/ - - // check the structure of the new options array to determine if it is a section list or a - const isSectionList = newOptions?.some( - (item) => item.title && item.data && Array.isArray(item.data) - ); - - const ListTypeComponent = isSectionList - ? DropdownSectionList - : DropdownFlatList; - const modifiedSectionData = extractPropertyFromArray( - newOptions, - 'data' - )?.flat(); - - /** - * `options` is the original array, it never changes. (Do not use except you really need the original array) . - * `newOptions` is a copy of options but can be mutated by `setNewOptions`, as a result, the value may change. - * `modifiedOptions` should only be used for computations. It has the same structure for both `FlatList` and `SectionList` - */ - const modifiedOptions = isSectionList ? modifiedSectionData : newOptions; + const { + searchValue, + setSearchValue, + onSearch, + setFilteredOptions, + filteredOptions, + isSectionList, + } = useSearch({ + initialOptions: options, + optionLabel, + optionValue, + searchCallback: useCallback( + (value) => searchControls?.searchCallback?.(value), + [searchControls] + ), + }); /*=========================================== - * Selection handlers + * setIndexOfSelectedItem - For ScrollToIndex *==========================================*/ - const handleSingleSelection = (value: TSelectedItem) => { - if (selectedItem === value) { - setSelectedItem(''); - onValueChange(null); // send value to parent - } else { - setSelectedItem(value); - onValueChange(value); // send value to parent - - if (autoCloseOnSelect) { - setOpen(false); // close modal upon selection - } - } - }; - - const handleMultipleSelections = (value: TSelectedItem) => { - setSelectedItems((prevVal) => { - let selectedValues = [...prevVal]; - - if (selectedValues?.includes(value)) { - selectedValues = selectedValues.filter((item) => item !== value); - } else { - selectedValues.push(value); - } - onValueChange(selectedValues); // send value to parent - return selectedValues; + const { listIndex, setListIndex, setIndexOfSelectedItem } = + useIndexOfSelectedItem({ + options, + optionLabel, + isSectionList, }); - }; - - const removeDisabledItems = (items: TFlatList) => { - return items?.filter((item: TFlatListItem) => !item.disabled); - }; - - const handleSelectAll = () => { - setSelectAll((prevVal) => { - let selectedValues: TSelectedItem[] = []; - - // don't select disabled items - const filteredOptions = removeDisabledItems( - isSectionList - ? extractPropertyFromArray(options, 'data').flat() - : options - ); - - if (!prevVal) { - selectedValues = filteredOptions.map( - (obj) => obj[optionValue] - ) as TSelectedItem[]; - } - - setSelectedItems(selectedValues); - onValueChange(selectedValues); // send value to parent - return !prevVal; - }); - - if (typeof listControls?.selectAllCallback === 'function' && !selectAll) { - listControls.selectAllCallback(); - } - - if (typeof listControls?.unselectAllCallback === 'function' && selectAll) { - listControls.unselectAllCallback(); - } - }; /*=========================================== - * Handle side effects + * Reset component states *==========================================*/ - const checkSelectAll = useCallback( - (selectedValues: TSelectedItem[]) => { - //if the list contains disabled values, those values will not be selected - if ( - removeDisabledItems(modifiedOptions)?.length === selectedValues?.length - ) { - setSelectAll(true); - } else { - setSelectAll(false); - } - }, - [modifiedOptions] - ); - - // anytime the selected items change, check if it is time to set `selectAll` to true - useEffect(() => { - if (isMultiple) { - checkSelectAll(selectedItems); - } - return () => {}; - }, [checkSelectAll, isMultiple, selectedItems]); + const resetOptionsRelatedState = useCallback(() => { + setSearchValue(''); + setFilteredOptions(options); + setListIndex({ itemIndex: -1, sectionIndex: -1 }); + }, [filteredOptions, setFilteredOptions, setListIndex, setSearchValue]); /*=========================================== - * Get label handler + * Modal *==========================================*/ - const getSelectedItemsLabel = () => { - if (isMultiple && Array.isArray(selectedItems)) { - let selectedLabels: Array = []; + const { open, setOpen, openModal, closeModal } = useModal({ + hideModal, + modalProps, + onDismiss: modalControls?.modalProps?.onDismiss, + resetOptionsRelatedState, + disabled, + }); - selectedItems?.forEach((element: TSelectedItem) => { - let selectedItemLabel = modifiedOptions?.find( - (item: TFlatListItem) => item[optionValue] === element - )?.[optionLabel]; - selectedLabels.push(selectedItemLabel); - }); - return selectedLabels; + useEffect(() => { + if (hideModal) { + setOpen(false); } - - let selectedItemLabel = modifiedOptions?.find( - (item: TFlatListItem) => item[optionValue] === selectedItem - ); - return selectedItemLabel?.[optionLabel]; - }; - - /*=========================================== - * Search - *==========================================*/ - const onSearch = (value: string) => { - setSearchValue(value); - searchControls?.searchCallback?.(value); - - let searchText = escapeRegExp(value).toString().toLocaleLowerCase().trim(); - - const regexFilter = new RegExp(searchText, 'i'); - - // Because the options array will be mutated while searching, we have to search with the original array - const searchResults = isSectionList - ? searchSectionList(options as TSectionList, regexFilter) - : searchFlatList(options as TFlatList, regexFilter); - - setNewOptions(searchResults); - }; - - const searchFlatList = (flatList: TFlatList, regexFilter: RegExp) => { - const searchResults = flatList.filter((item: TFlatListItem) => { - if ( - item[optionLabel].toString().toLowerCase().search(regexFilter) !== -1 || - item[optionValue].toString().toLowerCase().search(regexFilter) !== -1 - ) { - return true; - } - return false; - }); - return searchResults; - }; - - const searchSectionList = ( - sectionList: TSectionList, - regexFilter: RegExp - ) => { - const searchResults = sectionList.map((listItem: TSectionListItem) => { - const filteredData = listItem.data.filter((item: TFlatListItem) => { - if ( - item[optionLabel].toString().toLowerCase().search(regexFilter) !== - -1 || - item[optionValue].toString().toLowerCase().search(regexFilter) !== -1 - ) { - return true; - } - return false; - }); - - return { ...listItem, data: filteredData }; - }); - - return searchResults; - }; + return () => {}; + }, [hideModal, setOpen]); /** * To prevent triggering on modalProps.onDismiss on first render, we perform this check @@ -299,59 +142,70 @@ export const DropdownSelect: React.FC = ({ } hasComponentBeenRendered.current = true; - }, [open]); + }, [open, modalControls?.modalProps]); /*=========================================== - * Modal + * Single and multiple selection Hook *==========================================*/ - const openModal = () => { - if (disabled) { - return; - } - setOpen(true); - resetComponent(); - }; - - const closeModal = () => { - setOpen(false); - resetComponent(); - }; - - const resetComponent = () => { - setSearchValue(''); - setNewOptions(options); - setListIndex({ itemIndex: -1, sectionIndex: -1 }); - }; + const { + selectedItem, + selectedItems, + setSelectedItem, + setSelectedItems, + handleSingleSelection, + handleMultipleSelections, + } = useSelectionHandler({ + initialSelectedValue: selectedValue, + isMultiple, + maxSelectableItems, + onValueChange, + setOpen, + autoCloseOnSelect, + }); useEffect(() => { - if (hideModal) { - setOpen(false); - } + isMultiple + ? setSelectedItems(Array.isArray(selectedValue) ? selectedValue : []) + : setSelectedItem((selectedValue as TSelectedItem) || ''); + return () => {}; - }, [hideModal]); + }, [ + selectedValue, + setSelectedItems, + setSelectedItem, + isMultiple, + onValueChange, + ]); /*=========================================== - * setIndexOfSelectedItem - For ScrollToIndex + * List type *==========================================*/ - const setIndexOfSelectedItem = (selectedLabel: string) => { - isSectionList - ? (options as TSectionListItem[] | undefined)?.map( - (item: TSectionListItem, sectionIndex: number) => { - item?.data?.find((dataItem: TFlatListItem, itemIndex: number) => { - if (dataItem[optionLabel] === selectedLabel) { - setListIndex({ sectionIndex, itemIndex }); - } - }); - } - ) - : (options as TFlatListItem[] | undefined)?.find( - (item: TFlatListItem, itemIndex: number) => { - if (item[optionLabel] === selectedLabel) { - setListIndex({ itemIndex }); - } - } - ); - }; + const ListTypeComponent = isSectionList + ? DropdownSectionList + : DropdownFlatList; + const modifiedSectionData = extractPropertyFromArray( + filteredOptions, + 'data' + )?.flat(); + + /** + * `options` is the original array, it never changes. (Do not use except you really need the original array) . + * `filteredOptions` is a copy of options but can be mutated by `setFilteredOptions`, as a result, the value may change. + * `modifiedOptions` should only be used for computations. It has the same structure for both `FlatList` and `SectionList` + */ + const modifiedOptions = isSectionList ? modifiedSectionData : filteredOptions; + + /*=========================================== + * Select all Hook + *==========================================*/ + const { selectAll, handleSelectAll } = useSelectAll({ + options: modifiedOptions, + selectedItems, + isMultiple, + onValueChange, + listControls, + optionValue, + }); return ( <> @@ -361,7 +215,16 @@ export const DropdownSelect: React.FC = ({ placeholder={placeholder} helperText={helperText} error={error} - getSelectedItemsLabel={getSelectedItemsLabel} + getSelectedItemsLabel={() => + getSelectedItemsLabel({ + isMultiple, + optionLabel, + optionValue, + selectedItem, + selectedItems, + modifiedOptions, + }) + } selectedItem={selectedItem} selectedItems={selectedItems} openModal={openModal} @@ -406,6 +269,9 @@ export const DropdownSelect: React.FC = ({ placeholder={ searchControls?.textInputProps?.placeholder || 'Search' } + aria-label={ + searchControls?.textInputProps?.placeholder || 'Search' + } {...searchControls?.textInputProps} /> )} @@ -438,7 +304,7 @@ export const DropdownSelect: React.FC = ({ } ListFooterComponent={listFooterComponent} listComponentStyles={listComponentStyles} - options={newOptions} + options={filteredOptions} optionLabel={optionLabel} optionValue={optionValue} isMultiple={isMultiple} diff --git a/src/types/index.types.ts b/src/types/index.types.ts index d79458b..2a28a63 100644 --- a/src/types/index.types.ts +++ b/src/types/index.types.ts @@ -20,9 +20,10 @@ export type CommonDropdownProps = { options: TFlatList | TSectionList; optionLabel?: string; optionValue?: string; - onValueChange: Function; - selectedValue?: TSelectedItem | TSelectedItem[]; + onValueChange: (selectedItems: TSelectedItem | TSelectedItem[]) => void; + selectedValue: TSelectedItem | TSelectedItem[]; autoCloseOnSelect?: boolean; + maxSelectableItems?: number; }; export type TDropdownInputProps = { @@ -118,10 +119,11 @@ export type TListControls = { }; export type TSelectedItem = string | number | boolean; +export type TSelectedItemWithReactComponent = TSelectedItem | React.JSX.Element; export type TFlatList = TFlatListItem[]; export type TFlatListItem = { - [key: string]: TSelectedItem | React.JSX.Element; + [key: string]: TSelectedItemWithReactComponent; }; export type TSectionList = TSectionListItem[]; diff --git a/src/utils/index.ts b/src/utils/index.ts index c91a73b..df3a602 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,6 +1,10 @@ -/** - * Extract property from array - */ +import { TSelectedItem } from '../types/index.types'; +import { + TFlatList, + TFlatListItem, + TSectionList, + TSelectedItemWithReactComponent, +} from 'src/types/index.types'; export const extractPropertyFromArray = (arr: any[], property: string) => { let extractedValue = arr?.map((item: any) => item[property]); @@ -11,3 +15,56 @@ export const extractPropertyFromArray = (arr: any[], property: string) => { export const escapeRegExp = (text: string) => { return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }; + +export const removeDisabledItems = (items: TFlatList) => { + return items?.filter((item: TFlatListItem) => !item.disabled); +}; + +export const isSectionList = (options: TFlatList | TSectionList): boolean => { + return (options as TSectionList).some( + (item) => item.title && item.data && Array.isArray(item.data) + ); +}; + +/** + * + * @description get the labels of the items that were selected from the options array + * @returns + */ +export const getSelectedItemsLabel = ({ + isMultiple, + optionLabel, + optionValue, + selectedItem, + selectedItems, + modifiedOptions, +}: { + isMultiple: boolean; + optionLabel: string; + optionValue: string; + selectedItem: TSelectedItem; + selectedItems: TSelectedItem[]; + modifiedOptions: TFlatList; +}) => { + // Multiple select + if (isMultiple && Array.isArray(selectedItems)) { + let selectedLabels: TSelectedItemWithReactComponent[] = []; + + selectedItems?.forEach((element: TSelectedItem) => { + let selectedItemLabel = modifiedOptions?.find( + (item: TFlatListItem) => item[optionValue] === element + )?.[optionLabel]; + + if (selectedItemLabel) { + selectedLabels.push(selectedItemLabel); + } + }); + return selectedLabels; + } + + // Single select + let selectedItemLabel = modifiedOptions?.find( + (item: TFlatListItem) => item[optionValue] === selectedItem + ); + return selectedItemLabel?.[optionLabel]; +}; diff --git a/yarn.lock b/yarn.lock index 1f4eeb1..aa6fc9d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,6 +15,13 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" +"@babel/code-frame@7.12.11": + version "7.12.11" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.12.11.tgz#f4ad435aa263db935b8f10f2c552d23fb716a63f" + integrity sha512-Zt1yodBx1UcyiePMSkWnU4hPqhwq7hGi2nFL1LeA3EUl+q2LQx16MISgJ0+z7dnmgvP9QtIleuETGOiOH1RcIw== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.21.4", "@babel/code-frame@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.7.tgz#882fd9e09e8ee324e496bd040401c6f046ef4465" @@ -264,7 +271,7 @@ "@babel/template" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/highlight@^7.24.7": +"@babel/highlight@^7.10.4", "@babel/highlight@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.7.tgz#a05ab1df134b286558aae0ed41e6c5f731bf409d" integrity sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw== @@ -1380,45 +1387,26 @@ dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.4.0", "@eslint-community/regexpp@^4.6.1": +"@eslint-community/regexpp@^4.4.0": version "4.10.1" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.10.1.tgz#361461e5cb3845d874e61731c11cfedd664d83a0" integrity sha512-Zm2NGpWELsQAD1xsJzGQpYfvICSsFkEpU0jxBjfdC6uNEWXcHnfs9hScFWtXVDVl+rBQJGrl4g1vcKIejpH9dA== -"@eslint/config-array@^0.16.0": - version "0.16.0" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.16.0.tgz#bb3364fc39ee84ec3a62abdc4b8d988d99dfd706" - integrity sha512-/jmuSd74i4Czf1XXn7wGRWZCuyaUZ330NH1Bek0Pplatt4Sy1S5haN21SCLLdbeKslQ+S0wEJ+++v5YibSi+Lg== - dependencies: - "@eslint/object-schema" "^2.1.4" - debug "^4.3.1" - minimatch "^3.0.5" - -"@eslint/eslintrc@^3.1.0": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.1.0.tgz#dbd3482bfd91efa663cbe7aa1f506839868207b6" - integrity sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ== +"@eslint/eslintrc@^0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-0.4.3.tgz#9e42981ef035beb3dd49add17acb96e8ff6f394c" + integrity sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw== dependencies: ajv "^6.12.4" - debug "^4.3.2" - espree "^10.0.1" - globals "^14.0.0" - ignore "^5.2.0" + debug "^4.1.1" + espree "^7.3.0" + globals "^13.9.0" + ignore "^4.0.6" import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" + js-yaml "^3.13.1" + minimatch "^3.0.4" strip-json-comments "^3.1.1" -"@eslint/js@9.5.0": - version "9.5.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.5.0.tgz#0e9c24a670b8a5c86bff97b40be13d8d8f238045" - integrity sha512-A7+AOT2ICkodvtsWnxZP4Xxk3NbZ3VMHd8oihydLRGrJgqqdEz1qSeEgXYyT/Cu8h1TWWsQRejIx48mtjZ5y1w== - -"@eslint/object-schema@^2.1.4": - version "2.1.4" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.4.tgz#9e69f8bb4031e11df79e03db09f9dbbae1740843" - integrity sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ== - "@hapi/hoek@^9.0.0", "@hapi/hoek@^9.3.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -1431,15 +1419,19 @@ dependencies: "@hapi/hoek" "^9.0.0" -"@humanwhocodes/module-importer@^1.0.1": - version "1.0.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz#af5b2691a22b44be847b0ca81641c5fb6ad0172c" - integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== +"@humanwhocodes/config-array@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.5.0.tgz#1407967d4c6eecd7388f83acf1eaf4d0c6e58ef9" + integrity sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg== + dependencies: + "@humanwhocodes/object-schema" "^1.2.0" + debug "^4.1.1" + minimatch "^3.0.4" -"@humanwhocodes/retry@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.0.tgz#6d86b8cb322660f03d3f0aa94b99bdd8e172d570" - integrity sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew== +"@humanwhocodes/object-schema@^1.2.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45" + integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA== "@hutson/parse-repository-url@^5.0.0": version "5.0.0" @@ -1754,7 +1746,7 @@ resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== -"@nodelib/fs.walk@^1.2.3", "@nodelib/fs.walk@^1.2.8": +"@nodelib/fs.walk@^1.2.3": version "1.2.8" resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== @@ -1857,11 +1849,6 @@ dependencies: "@octokit/openapi-types" "^22.2.0" -"@pkgr/core@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" - integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== - "@pnpm/config.env-replace@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz#ab29da53df41e8948a00f2433f085f54de8b3a4c" @@ -2585,12 +2572,17 @@ accepts@^1.3.7, accepts@~1.3.5, accepts@~1.3.7: mime-types "~2.1.34" negotiator "0.6.3" -acorn-jsx@^5.3.2: +acorn-jsx@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.12.0, acorn@^8.8.2: +acorn@^7.4.0: + version "7.4.1" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa" + integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A== + +acorn@^8.8.2: version "8.12.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.12.0.tgz#1627bfa2e058148036133b8d9b51a700663c294c" integrity sha512-RTvkC4w+KNXrM39/lWCUaG0IbRkWdCv7W/IOW9oU6SawyxulvkQy5HQPVTKxEjczcUvapcrw3cFx/60VN/NRNw== @@ -2615,7 +2607,7 @@ aggregate-error@^3.0.0: clean-stack "^2.0.0" indent-string "^4.0.0" -ajv@^6.12.4: +ajv@^6.10.0, ajv@^6.12.4: version "6.12.6" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== @@ -2625,6 +2617,16 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" +ajv@^8.0.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" + integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== + dependencies: + fast-deep-equal "^3.1.3" + fast-uri "^3.0.1" + json-schema-traverse "^1.0.0" + require-from-string "^2.0.2" + ajv@^8.11.0: version "8.16.0" resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.16.0.tgz#22e2a92b94f005f7e0f9c9d39652ef0b8f6f0cb4" @@ -2647,6 +2649,11 @@ ansi-align@^3.0.1: dependencies: string-width "^4.1.0" +ansi-colors@^4.1.1: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" + integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== + ansi-escapes@^4.2.1, ansi-escapes@^4.3.2: version "4.3.2" resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" @@ -2867,6 +2874,11 @@ astral-regex@^1.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" integrity sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg== +astral-regex@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" + integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== + async-limiter@~1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" @@ -3708,13 +3720,20 @@ debug@2.6.9, debug@^2.2.0, debug@^2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: +debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4: version "4.3.5" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.5.tgz#e83444eceb9fedd4a1da56d671ae2446a01a6e1e" integrity sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg== dependencies: ms "2.1.2" +debug@^4.0.1: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" @@ -3872,6 +3891,13 @@ doctrine@^2.1.0: dependencies: esutils "^2.0.2" +doctrine@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961" + integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w== + dependencies: + esutils "^2.0.2" + dom-accessibility-api@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz#993e925cc1d73f2c662e7d75dd5a5445259a8fd8" @@ -3938,6 +3964,14 @@ end-of-stream@^1.1.0: dependencies: once "^1.4.0" +enquirer@^2.3.5: + version "2.4.1" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.4.1.tgz#93334b3fbd74fc7097b224ab4a8fb7e40bf4ae56" + integrity sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ== + dependencies: + ansi-colors "^4.1.1" + strip-ansi "^6.0.1" + env-paths@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" @@ -4147,16 +4181,16 @@ escodegen@^2.1.0: optionalDependencies: source-map "~0.6.1" +eslint-config-prettier@^7.0.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-7.2.0.tgz#f4a4bd2832e810e8cc7c1411ec85b3e85c0c53f9" + integrity sha512-rV4Qu0C3nfJKPOAhFujFxB7RMP+URFyQqqOZW9DMRD7ZDTFyjaIlETU3xzHELt++4ugC0+Jm084HQYkkJe+Ivg== + eslint-config-prettier@^8.5.0: version "8.10.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-8.10.0.tgz#3a06a662130807e2502fc3ff8b4143d8a0658e11" integrity sha512-SM8AMJdeQqRYT9O9zguiruQZaN7+z+E4eAP9oiLNGKMtomwaB1E9dcgUD6ZAn/eQAb52USbvezbiljfZUhbJcg== -eslint-config-prettier@^9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz#31af3d94578645966c082fcb71a5846d3c94867f" - integrity sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw== - eslint-plugin-eslint-comments@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/eslint-plugin-eslint-comments/-/eslint-plugin-eslint-comments-3.2.0.tgz#9e1cd7b4413526abb313933071d7aba05ca12ffa" @@ -4180,6 +4214,13 @@ eslint-plugin-jest@^26.5.3: dependencies: "@typescript-eslint/utils" "^5.10.0" +eslint-plugin-prettier@^3.1.3: + version "3.4.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-3.4.1.tgz#e9ddb200efb6f3d05ffe83b1665a716af4a387e5" + integrity sha512-htg25EUYUeIhKHXjOinK4BgCcDwtLHjqaxCDsMy5nbnUMkKFvIhMVCp+5GFUXQ4Nr8lBsPqtGAqBenbpFqAA2g== + dependencies: + prettier-linter-helpers "^1.0.0" + eslint-plugin-prettier@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-4.2.1.tgz#651cbb88b1dab98bfd42f017a12fa6b2d993f94b" @@ -4187,14 +4228,6 @@ eslint-plugin-prettier@^4.2.1: dependencies: prettier-linter-helpers "^1.0.0" -eslint-plugin-prettier@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz#17cfade9e732cef32b5f5be53bd4e07afd8e67e1" - integrity sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw== - dependencies: - prettier-linter-helpers "^1.0.0" - synckit "^0.8.6" - eslint-plugin-react-hooks@^4.6.0: version "4.6.2" resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" @@ -4244,15 +4277,19 @@ eslint-scope@5.1.1, eslint-scope@^5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.0.1.tgz#a9601e4b81a0b9171657c343fb13111688963cfc" - integrity sha512-pL8XjgP4ZOmmwfFE8mEhSxA7ZY4C+LWyqjQ3o4yWkkmD0qcMT9kkW3zWHOczhWcjTSgqycYAgwSlXvZltv65og== +eslint-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-2.1.0.tgz#d2de5e03424e707dc10c74068ddedae708741b27" + integrity sha512-w94dQYoauyvlDc43XnGB8lU3Zt713vNChgt4EWwhXAP2XkBvndfxF0AgIqKOOasjPIPzj9JqgwkwbCYD0/V3Zg== dependencies: - esrecurse "^4.3.0" - estraverse "^5.2.0" + eslint-visitor-keys "^1.1.0" + +eslint-visitor-keys@^1.1.0, eslint-visitor-keys@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-1.3.0.tgz#30ebd1ef7c2fdff01c3a4f151044af25fab0523e" + integrity sha512-6J72N8UNa462wa/KFODt/PJ3IU60SDpC3QXC1Hjc1BXXpfL2C9R5+AU7jhe0F6GREqVMh4Juu+NY7xn+6dipUQ== -eslint-visitor-keys@^2.1.0: +eslint-visitor-keys@^2.0.0, eslint-visitor-keys@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303" integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw== @@ -4262,69 +4299,70 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.0.0.tgz#e3adc021aa038a2a8e0b2f8b0ce8f66b9483b1fb" - integrity sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw== - -eslint@^9.2.0: - version "9.5.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.5.0.tgz#11856034b94a9e1a02cfcc7e96a9f0956963cd2f" - integrity sha512-+NAOZFrW/jFTS3dASCGBxX1pkFD0/fsO+hfAkJ4TyYKwgsXZbqzrw+seCYFCcPCYXvnD67tAnglU7GQTz6kcVw== +eslint@^7.2.0: + version "7.32.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" + integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" - "@eslint-community/regexpp" "^4.6.1" - "@eslint/config-array" "^0.16.0" - "@eslint/eslintrc" "^3.1.0" - "@eslint/js" "9.5.0" - "@humanwhocodes/module-importer" "^1.0.1" - "@humanwhocodes/retry" "^0.3.0" - "@nodelib/fs.walk" "^1.2.8" - ajv "^6.12.4" + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.3" + "@humanwhocodes/config-array" "^0.5.0" + ajv "^6.10.0" chalk "^4.0.0" cross-spawn "^7.0.2" - debug "^4.3.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" escape-string-regexp "^4.0.0" - eslint-scope "^8.0.1" - eslint-visitor-keys "^4.0.0" - espree "^10.0.1" - esquery "^1.5.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.1" + esquery "^1.4.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" - file-entry-cache "^8.0.0" - find-up "^5.0.0" - glob-parent "^6.0.2" - ignore "^5.2.0" + file-entry-cache "^6.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.1.2" + globals "^13.6.0" + ignore "^4.0.6" + import-fresh "^3.0.0" imurmurhash "^0.1.4" is-glob "^4.0.0" - is-path-inside "^3.0.3" + js-yaml "^3.13.1" json-stable-stringify-without-jsonify "^1.0.1" levn "^0.4.1" lodash.merge "^4.6.2" - minimatch "^3.1.2" + minimatch "^3.0.4" natural-compare "^1.4.0" - optionator "^0.9.3" - strip-ansi "^6.0.1" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^6.0.9" text-table "^0.2.0" + v8-compile-cache "^2.0.3" -espree@^10.0.1: - version "10.1.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.1.0.tgz#8788dae611574c0f070691f522e4116c5a11fc56" - integrity sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA== +espree@^7.3.0, espree@^7.3.1: + version "7.3.1" + resolved "https://registry.yarnpkg.com/espree/-/espree-7.3.1.tgz#f2df330b752c6f55019f8bd89b7660039c1bbbb6" + integrity sha512-v3JCNCE64umkFpmkFGqzVKsOT0tN1Zr+ueqLZfpV1Ob8e+CEgPWa+OxCoGH3tnhimMKIaBm4m/vaRpJ/krRz2g== dependencies: - acorn "^8.12.0" - acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.0.0" + acorn "^7.4.0" + acorn-jsx "^5.3.1" + eslint-visitor-keys "^1.3.0" esprima@^4.0.0, esprima@^4.0.1, esprima@~4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== -esquery@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.5.0.tgz#6ce17738de8577694edd7361c57182ac8cb0db0b" - integrity sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg== +esquery@^1.4.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" + integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== dependencies: estraverse "^5.1.0" @@ -4461,6 +4499,11 @@ fast-levenshtein@^2.0.6: resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-uri@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.1.tgz#cddd2eecfc83a71c1be2cc2ef2061331be8a7134" + integrity sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw== + fast-xml-parser@^4.0.12, fast-xml-parser@^4.2.4: version "4.4.0" resolved "https://registry.yarnpkg.com/fast-xml-parser/-/fast-xml-parser-4.4.0.tgz#341cc98de71e9ba9e651a67f41f1752d1441a501" @@ -4490,12 +4533,12 @@ fetch-blob@^3.1.2, fetch-blob@^3.1.4: node-domexception "^1.0.0" web-streams-polyfill "^3.0.3" -file-entry-cache@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-8.0.0.tgz#7787bddcf1131bffb92636c69457bbc0edd6d81f" - integrity sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ== +file-entry-cache@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027" + integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg== dependencies: - flat-cache "^4.0.0" + flat-cache "^3.0.4" fill-range@^7.1.1: version "7.1.1" @@ -4566,13 +4609,14 @@ find-up@^7.0.0: path-exists "^5.0.0" unicorn-magic "^0.1.0" -flat-cache@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-4.0.1.tgz#0ece39fcb14ee012f4b0410bd33dd9c1f011127c" - integrity sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw== +flat-cache@^3.0.4: + version "3.2.0" + resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.2.0.tgz#2c0c2d5040c99b1632771a9d105725c0115363ee" + integrity sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw== dependencies: flatted "^3.2.9" - keyv "^4.5.4" + keyv "^4.5.3" + rimraf "^3.0.2" flatted@^3.2.9: version "3.3.1" @@ -4665,6 +4709,11 @@ function.prototype.name@^1.1.5, function.prototype.name@^1.1.6: es-abstract "^1.22.1" functions-have-names "^1.2.3" +functional-red-black-tree@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" + integrity sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g== + functions-have-names@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" @@ -4776,13 +4825,6 @@ glob-parent@^5.1.2: dependencies: is-glob "^4.0.1" -glob-parent@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - glob@^7.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" @@ -4825,10 +4867,12 @@ globals@^11.1.0: resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== -globals@^14.0.0: - version "14.0.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" - integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== +globals@^13.6.0, globals@^13.9.0: + version "13.24.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-13.24.0.tgz#8432a19d78ce0c1e833949c36adb345400bb1171" + integrity sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ== + dependencies: + type-fest "^0.20.2" globalthis@^1.0.3: version "1.0.4" @@ -5091,6 +5135,11 @@ ieee754@^1.1.13: resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== +ignore@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" + integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg== + ignore@^5.0.5, ignore@^5.2.0, ignore@^5.2.4: version "5.3.1" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.1.tgz#5073e554cd42c5b33b394375f538b8593e34d4ef" @@ -5111,7 +5160,7 @@ import-fresh@^2.0.0: caller-path "^2.0.0" resolve-from "^3.0.0" -import-fresh@^3.2.1, import-fresh@^3.3.0: +import-fresh@^3.0.0, import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== @@ -5448,7 +5497,7 @@ is-path-cwd@^2.2.0: resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== -is-path-inside@^3.0.2, is-path-inside@^3.0.3: +is-path-inside@^3.0.2: version "3.0.3" resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== @@ -6231,7 +6280,7 @@ jsonparse@^1.2.0: object.assign "^4.1.4" object.values "^1.1.6" -keyv@^4.5.3, keyv@^4.5.4: +keyv@^4.5.3: version "4.5.4" resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93" integrity sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw== @@ -6380,6 +6429,11 @@ lodash.throttle@^4.1.1: resolved "https://registry.yarnpkg.com/lodash.throttle/-/lodash.throttle-4.1.1.tgz#c23e91b710242ac70c37f1e1cda9274cc39bf2f4" integrity sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ== +lodash.truncate@^4.4.2: + version "4.4.2" + resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193" + integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw== + lodash.uniq@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" @@ -6790,7 +6844,7 @@ min-indent@^1.0.0: resolved "https://registry.yarnpkg.com/min-indent/-/min-indent-1.0.1.tgz#a63f681673b30571fbe8bc25686ae746eefa9869" integrity sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg== -minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: +minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -6831,7 +6885,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3: +ms@2.1.3, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -7101,7 +7155,7 @@ open@^7.0.3: is-docker "^2.0.0" is-wsl "^2.1.1" -optionator@^0.9.3: +optionator@^0.9.1: version "0.9.4" resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.4.tgz#7ea1c1a5d91d764fb282139c88fe11e182a3a734" integrity sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g== @@ -7410,10 +7464,10 @@ prettier-linter-helpers@^1.0.0: dependencies: fast-diff "^1.1.2" -prettier@^3.2.5: - version "3.3.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.2.tgz#03ff86dc7c835f2d2559ee76876a3914cec4a90a" - integrity sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA== +prettier@^2.0.5: + version "2.8.8" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" + integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== pretty-format@^26.5.2, pretty-format@^26.6.2: version "26.6.2" @@ -7439,6 +7493,11 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +progress@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/progress/-/progress-2.0.3.tgz#7e8cf8d8f5b8f239c1bc68beb4eb78567d572ef8" + integrity sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA== + promise.allsettled@1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/promise.allsettled/-/promise.allsettled-1.0.7.tgz#b9dd51e9cffe496243f5271515652c468865f2d8" @@ -7814,6 +7873,11 @@ regexp.prototype.flags@^1.5.2: es-errors "^1.3.0" set-function-name "^2.0.1" +regexpp@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2" + integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg== + regexpu-core@^5.3.1: version "5.3.2" resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.3.2.tgz#11a2b06884f3527aec3e93dbbf4a3b958a95546b" @@ -8094,6 +8158,11 @@ semver@^6.3.0, semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^7.2.1: + version "7.6.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" + integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== + send@0.18.0: version "0.18.0" resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" @@ -8237,6 +8306,15 @@ slice-ansi@^2.0.0: astral-regex "^1.0.0" is-fullwidth-code-point "^2.0.0" +slice-ansi@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-4.0.0.tgz#500e8dd0fd55b05815086255b3195adf2a45fe6b" + integrity sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ== + dependencies: + ansi-styles "^4.0.0" + astral-regex "^2.0.0" + is-fullwidth-code-point "^3.0.0" + smart-buffer@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" @@ -8515,7 +8593,7 @@ strip-indent@^3.0.0: dependencies: min-indent "^1.0.0" -strip-json-comments@^3.1.1: +strip-json-comments@^3.1.0, strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== @@ -8561,13 +8639,16 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -synckit@^0.8.6: - version "0.8.8" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.8.8.tgz#fe7fe446518e3d3d49f5e429f443cf08b6edfcd7" - integrity sha512-HwOKAP7Wc5aRGYdKH+dw0PRRpbO841v2DENBtjnR5HFWoiNByAl7vrx3p0G/rCyYXQsrxqtX48TImFtPcIHSpQ== +table@^6.0.9: + version "6.8.2" + resolved "https://registry.yarnpkg.com/table/-/table-6.8.2.tgz#c5504ccf201213fa227248bdc8c5569716ac6c58" + integrity sha512-w2sfv80nrAh2VCbqR5AK27wswXhqcck2AhfnNW76beQXskGZ1V12GwS//yYVa3d3fcvAip2OUnbDAjW2k3v9fA== dependencies: - "@pkgr/core" "^0.1.0" - tslib "^2.6.2" + ajv "^8.0.1" + lodash.truncate "^4.4.2" + slice-ansi "^4.0.0" + string-width "^4.2.3" + strip-ansi "^6.0.1" temp-dir@^2.0.0: version "2.0.0" @@ -8667,7 +8748,7 @@ tslib@^1.8.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.14.1.tgz#cf2d38bdc34a134bcaf1091c41f6619e2f672d00" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tslib@^2.0.1, tslib@^2.1.0, tslib@^2.6.2: +tslib@^2.0.1, tslib@^2.1.0: version "2.6.3" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== @@ -8691,6 +8772,11 @@ type-detect@4.0.8: resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== +type-fest@^0.20.2: + version "0.20.2" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" + integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== + type-fest@^0.21.3: version "0.21.3" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" @@ -8910,6 +8996,11 @@ utils-merge@1.0.1: resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== +v8-compile-cache@^2.0.3: + version "2.4.0" + resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.4.0.tgz#cdada8bec61e15865f05d097c5f4fd30e94dc128" + integrity sha512-ocyWc3bAHBB/guyqJQVI5o4BZkPhznPYUG2ea80Gond/BgNWpap8TOmLSeeQG7bnh2KMISxskdADG59j7zruhw== + v8-to-istanbul@^9.0.1: version "9.3.0" resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz#b9572abfa62bd556c16d75fdebc1a411d5ff3175" From e4d3db3682275c1bbbf27d87429cade3be82d7fd Mon Sep 17 00:00:00 2001 From: Azeezat Date: Mon, 9 Sep 2024 03:52:15 -0700 Subject: [PATCH 02/13] fix: updated test action --- .github/workflows/coverage.yml | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 6136fff..938feb9 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -1,19 +1,12 @@ -name: Code Coverage Summary Report +name: 'coverage' on: - push: - branches: [ "main" ] - pull_request: - # The branches below must be a subset of the branches above - branches: [ "main" ] + pull_request: + branches: [ "main" ] jobs: - analyze: - name: Analyze + coverage: runs-on: ubuntu-latest - permissions: - actions: read - contents: read steps: - - name: Code coverage report - uses: irongut/CodeCoverageSummary@v1.3.0 + - uses: actions/checkout@v3 + - uses: ArtiomTr/jest-coverage-report-action@v2 with: - filename: coverage.cobertura.xml \ No newline at end of file + test-script: npm test From 3a4a9cee04511e60aec0860d51f09e0ccebeb29a Mon Sep 17 00:00:00 2001 From: Azeezat Date: Mon, 9 Sep 2024 03:54:53 -0700 Subject: [PATCH 03/13] chore: code coverage action fix --- .github/workflows/coverage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml index 938feb9..a878787 100644 --- a/.github/workflows/coverage.yml +++ b/.github/workflows/coverage.yml @@ -9,4 +9,5 @@ jobs: - uses: actions/checkout@v3 - uses: ArtiomTr/jest-coverage-report-action@v2 with: + package-manager: yarn test-script: npm test From 897c1c97ae737dd82f66dac2d1213dc343afe05b Mon Sep 17 00:00:00 2001 From: Azeezat Date: Wed, 11 Sep 2024 22:37:11 -0700 Subject: [PATCH 04/13] fix: modal refactor + unit tests --- README.md | 4 +- src/__tests__/empty-dropdown.test.tsx | 16 ++- src/__tests__/flat-list-dropdown.test.tsx | 31 +++- src/__tests__/section-list-dropdown.test.tsx | 91 +++++++++++- src/components/CheckBox/checkbox.types.ts | 2 +- src/components/CheckBox/index.tsx | 4 +- src/components/CustomModal/index.tsx | 136 +++++++++++------- src/components/Dropdown/Dropdown.tsx | 1 + src/components/Dropdown/DropdownFlatList.tsx | 16 ++- .../Dropdown/DropdownSectionList.tsx | 14 +- src/hooks/use-modal.ts | 38 ++--- src/hooks/use-selection-handler.ts | 8 +- src/index.tsx | 52 ++----- src/types/index.types.ts | 13 +- src/utils/index.ts | 2 +- 15 files changed, 262 insertions(+), 166 deletions(-) diff --git a/README.md b/README.md index 0ca35c2..b10e538 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ [![NPM](https://nodei.co/npm/react-native-input-select.png?downloads=true)](https://nodei.co/npm/react-native-input-select/) [![npm version](https://badge.fury.io/js/react-native-input-select.svg)](https://badge.fury.io/js/react-native-input-select) [![GitHub stars](https://img.shields.io/github/stars/azeezat/react-native-select?style=social)](https://github.com/azeezat/react-native-select/stargazers) [![CodeQL](https://github.com/azeezat/react-native-select/actions/workflows/codeql.yml/badge.svg)](https://github.com/azeezat/react-native-select/actions/workflows/codeql.yml) [![Release & Publish to NPM](https://github.com/azeezat/react-native-select/actions/workflows/release-and-publish-to-npm.yml/badge.svg)](https://github.com/azeezat/react-native-select/actions/workflows/release-and-publish-to-npm.yml) +[![react-native-input-select](https://snyk.io/advisor/npm-package/react-native-input-select/badge.svg)](https://snyk.io/advisor/npm-package/react-native-input-select) # react-native-input-select @@ -277,8 +278,7 @@ For more examples visit our [wiki page](https://github.com/azeezat/react-native- | listControls | `Object` | `{ selectAllText: 'Choose all', unselectAllText: 'Remove all', selectAllCallback: () => {}, unselectAllCallback: () => {}, hideSelectAll: boolean, emptyListMessage: 'No record found'}` | | searchControls | `Object` | `{ textInputStyle: ViewStyle \| TextStyle, textInputContainerStyle: ViewStyle, textInputProps: TextInputProps, searchCallback:(value)=>{}}` | | modalControls | `Object` | `{ modalBackgroundStyle: ViewStyle, modalOptionsContainerStyle: ViewStyle, modalProps: ModalProps}` | -| maxSelectableItems | `number` | 5 | - +| maxSelectableItems | `number` | 5 | ## Deprecation Notice diff --git a/src/__tests__/empty-dropdown.test.tsx b/src/__tests__/empty-dropdown.test.tsx index 41c8fe6..03d0c2c 100644 --- a/src/__tests__/empty-dropdown.test.tsx +++ b/src/__tests__/empty-dropdown.test.tsx @@ -2,6 +2,14 @@ import React from 'react'; import DropdownSelect from '../index'; import { render, screen, userEvent } from '@testing-library/react-native'; import '@testing-library/jest-dom'; +import { PlatformOSType } from 'react-native'; + +export const mockPlatform = (OS: PlatformOSType) => { + jest.doMock('react-native/Libraries/Utilities/Platform', () => ({ + OS, + select: (config: { [x: string]: any }) => config[OS], + })); +}; describe('Initial state of component', () => { beforeAll(() => { @@ -12,6 +20,8 @@ describe('Initial state of component', () => { jest.useRealTimers(); }); + const user = userEvent.setup(); + // TODO: test these mocks once you are able to simulate specific device types like android and iOS const mockOpenModal = jest.fn(); const mockCloseModal = jest.fn(); @@ -56,7 +66,6 @@ describe('Initial state of component', () => { }); test('open and close modal', async () => { - const user = userEvent.setup(); render(defaultDropdown); //open modal when dropdown is clicked @@ -67,6 +76,10 @@ describe('Initial state of component', () => { const closeModal = screen.getByLabelText('close modal'); await user.press(closeModal); expect(screen.getByText(placeholder)); + + //check if callback was called on android + mockPlatform('android'); + expect(mockCloseModal).toHaveBeenCalledTimes(1); }); const disabledDropdown = ( @@ -91,7 +104,6 @@ describe('Initial state of component', () => { }); test('Disabled dropdown should not be clickable', async () => { - const user = userEvent.setup(); render(disabledDropdown); let dropdownInput = screen.getByTestId('dropdown-input-container'); diff --git a/src/__tests__/flat-list-dropdown.test.tsx b/src/__tests__/flat-list-dropdown.test.tsx index 1046a84..5d52522 100644 --- a/src/__tests__/flat-list-dropdown.test.tsx +++ b/src/__tests__/flat-list-dropdown.test.tsx @@ -14,6 +14,8 @@ describe('Initial state of component', () => { jest.useRealTimers(); }); + const user = userEvent.setup(); + const options: TFlatList = [ { name: '🍛 Rice', value: '1', disabled: true }, { name: 🍗 Chicken, value: '2' }, @@ -21,10 +23,12 @@ describe('Initial state of component', () => { { name: '🍕 Pizza', value: '4' }, ]; - const mockOnValueChange = jest.fn(); const placeholder = 'Select food'; const testId = 'section-list-test-id'; + const mockOnValueChange = jest.fn(); const mockSearchCallback = jest.fn(); + const mockSelectAllCallback = jest.fn(); + const mockUnselectAllCallback = jest.fn(); const flatListDropdown = ( { describe('Single select', () => { test('open modal when dropdown is clicked and select a single item', async () => { - const user = userEvent.setup(); render(flatListDropdown); await user.press(screen.getByText(placeholder)); expect(screen.getByText(options[0].name as string)); @@ -115,11 +118,14 @@ describe('Initial state of component', () => { optionValue="value" isMultiple isSearchable + listControls={{ + selectAllCallback: mockSelectAllCallback, + unselectAllCallback: mockUnselectAllCallback, + }} /> ); test('open modal when dropdown is clicked and select a multiple items', async () => { - const user = userEvent.setup(); render(flatListDropdownWithMultiSelect); await user.press(screen.getByText(placeholder)); @@ -137,7 +143,7 @@ describe('Initial state of component', () => { await user.press(thirdSelection); const forthSelection = screen.getByText(options[3].name as string); - expect(thirdSelection); + expect(forthSelection); await user.press(forthSelection); expect(mockOnValueChangeMultiSelect).toHaveBeenCalledTimes(2); @@ -145,5 +151,22 @@ describe('Initial state of component', () => { //`Clear All` should now be visible since all items in the list have been selected screen.getByText('Clear all'); }); + + test('select all / unselect all', async () => { + const user = userEvent.setup(); + render(flatListDropdownWithMultiSelect); + await user.press(screen.getByText(placeholder)); + + // select all + const selectAll = screen.getByText('Select all'); + await user.press(selectAll); + expect(mockSelectAllCallback).toHaveBeenCalledTimes(1); + + // unselect all + const clearAll = screen.getByText('Clear all'); //`Clear all` should now be visible since all items in the list have been selected + await user.press(clearAll); + expect(mockUnselectAllCallback).toHaveBeenCalledTimes(1); //`Select all` should now be visible since all items in the list have been deselected + screen.getByText('Select all'); //`Select all` should now be visible since all items in the list have been deselected + }); }); }); diff --git a/src/__tests__/section-list-dropdown.test.tsx b/src/__tests__/section-list-dropdown.test.tsx index d8b40f4..ff48880 100644 --- a/src/__tests__/section-list-dropdown.test.tsx +++ b/src/__tests__/section-list-dropdown.test.tsx @@ -13,8 +13,13 @@ describe('Initial state of component', () => { jest.useRealTimers(); }); - const mockSearchCallback = jest.fn(); + const user = userEvent.setup(); + const placeholder = 'Select an option'; + const mockOnValueChange = jest.fn(); + const mockSearchCallback = jest.fn(); + const mockSelectAllCallback = jest.fn(); + const mockUnselectAllCallback = jest.fn(); const options: TSectionList = [ { @@ -46,7 +51,7 @@ describe('Initial state of component', () => { {}} + onValueChange={mockOnValueChange} testID="section-list-test-id" isSearchable searchControls={{ @@ -97,10 +102,82 @@ describe('Initial state of component', () => { expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount + 1); //adding 1 because the clear event also called the search callback }); - test('open modal when dropdown is clicked', async () => { - const user = userEvent.setup(); - render(sectionListDropdown); - await user.press(screen.getByText(placeholder)); - expect(screen.getByText(options[0].title)); + describe('Single select', () => { + test('open modal when dropdown is clicked and select a single item', async () => { + render(sectionListDropdown); + await user.press(screen.getByText(placeholder)); + expect(screen.getByText(options[0].data[0].label as string)); + const optionToTestFor = screen.getByText( + options[0].data[2].label as string, + { exact: false } + ); + + expect(optionToTestFor); + + //select one option + await user.press(optionToTestFor); + expect(mockOnValueChange).toHaveBeenCalledTimes(1); + }); + }); + + describe.skip('Multiple select', () => { + let mockOnValueChangeMultiSelect = jest.fn(); + + const sectionListDropdownWithMultiSelect = ( + + ); + + test('open modal when dropdown is clicked and select a multiple items', async () => { + const user = userEvent.setup(); + render(sectionListDropdownWithMultiSelect); + await user.press(screen.getByText(placeholder)); + + let count = 0; + + // if there is a disabled item in the list, expect no call + options.map(async (section, i) => { + section.data.map(async (item, j) => { + const listItem = screen.getByText(item.label.toString()); + expect(listItem); + await user.press(listItem); + if (!item.disabled) { + count += 1; + } + }); + }); + + expect(mockOnValueChangeMultiSelect).toHaveBeenCalledTimes(count); + + //`Clear All` should now be visible since all items in the list have been selected + screen.getByText('Clear all'); + }); + + test('select all / unselect all', async () => { + render(sectionListDropdownWithMultiSelect); + await user.press(screen.getByText(placeholder)); + + // select all + const selectAll = screen.getByText('Select all'); + await user.press(selectAll); + expect(mockSelectAllCallback).toHaveBeenCalledTimes(1); + + // unselect all + const clearAll = screen.getByText('Clear all'); //`Clear all` should now be visible since all items in the list have been selected + await user.press(clearAll); + expect(mockUnselectAllCallback).toHaveBeenCalledTimes(1); + screen.getByText('Select all'); //`Select all` should now be visible since all items in the list have been deselected + }); }); }); diff --git a/src/components/CheckBox/checkbox.types.ts b/src/components/CheckBox/checkbox.types.ts index 5e38d34..6600fc6 100644 --- a/src/components/CheckBox/checkbox.types.ts +++ b/src/components/CheckBox/checkbox.types.ts @@ -1,5 +1,5 @@ import type { ColorValue } from 'react-native'; -import { TCheckboxControls } from 'src/types/index.types'; +import { TCheckboxControls } from '../../types/index.types'; export type CheckboxProps = { label?: string; diff --git a/src/components/CheckBox/index.tsx b/src/components/CheckBox/index.tsx index 9aec008..c176b92 100644 --- a/src/components/CheckBox/index.tsx +++ b/src/components/CheckBox/index.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { Pressable, Text, StyleSheet, Image, View } from 'react-native'; import { colors } from '../../styles/colors'; import { CHECKBOX_SIZE } from '../../constants'; -import type { CheckboxProps } from './checkbox.types'; +import { CheckboxProps } from './checkbox.types'; const CheckBox = ({ label, @@ -40,6 +40,7 @@ const CheckBox = ({ onPress={onChange ? () => onChange(!value) : null} style={[styles.checkboxContainer]} disabled={disabled} + aria-label={label} > {checkboxControls?.checkboxComponent || checkboxComponent || ( void; + close: () => void; +} // In iOS, `SafeAreaView` does not automatically account on keyboard. // Therefore, for iOS we need to wrap the content in `KeyboardAvoidingView`. -const ModalContentWrapper = ({ - children, -}: ScreenWrapperProps): ReactElement => { +const ModalContentWrapper = ({ children }: PropsWithChildren): ReactElement => { return Platform.OS === 'ios' ? ( {children} @@ -30,54 +39,71 @@ const ModalContentWrapper = ({ ); }; -const CustomModal = ({ - visible, - closeModal, - modalBackgroundStyle, //kept for backwards compatibility - modalOptionsContainerStyle, //kept for backwards compatibility - modalControls, - modalProps, //kept for backwards compatibility - children, -}: TCustomModalControls & ModalProps) => { - return ( - closeModal?.()} - animationType="fade" - {...modalControls?.modalProps} - {...modalProps} //kept for backwards compatibility - > - {/*Used to fix the select with search box behavior in iOS*/} - - - closeModal?.() || modalControls?.modalProps?.closeModal?.() - } - style={[ - styles.modalContainer, - styles.modalBackgroundStyle, - modalControls?.modalBackgroundStyle || modalBackgroundStyle, - ]} - aria-label="close modal" - > - {/* Added this `TouchableWithoutFeedback` wrapper because of the closing modal on expo web */} - {}}> - - {children} - - - - - - ); -}; +const CustomModal = forwardRef( + ( + { + modalBackgroundStyle, //kept for backwards compatibility + modalOptionsContainerStyle, //kept for backwards compatibility + modalControls, + modalProps, //kept for backwards compatibility + children, + }: CustomModalProps, + ref + ) => { + const [isVisible, setIsVisible] = useState(false); + + // customizes the instance value that is exposed to parent components when using ref + useImperativeHandle(ref, () => ({ + open: () => setIsVisible(true), + close: () => closeEvents(), + })); + + const closeEvents = () => { + setIsVisible(false); + modalControls?.modalProps?.closeModal?.(); //kept for backwards compatibility + modalProps?.onDismiss?.(); //kept for backwards compatibility + modalControls?.modalProps?.onDismiss?.(); + }; + + return ( + closeEvents()} + animationType="fade" + {...modalControls?.modalProps} + {...modalProps} //kept for backwards compatibility + > + {/*Used to fix the select with search box behavior in iOS*/} + + closeEvents()} + style={[ + styles.modalContainer, + styles.modalBackgroundStyle, + modalControls?.modalBackgroundStyle || modalBackgroundStyle, + ]} + aria-label="close modal" + > + {/* Added this `TouchableWithoutFeedback` wrapper because of the closing modal on expo web */} + {}}> + + {children} + + + + + + ); + } +); const styles = StyleSheet.create({ modalContainer: { diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index da1bee8..47d0d22 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -35,6 +35,7 @@ const Dropdown = ({ {label && label !== '' && ( diff --git a/src/components/Dropdown/DropdownFlatList.tsx b/src/components/Dropdown/DropdownFlatList.tsx index 905e684..485b197 100644 --- a/src/components/Dropdown/DropdownFlatList.tsx +++ b/src/components/Dropdown/DropdownFlatList.tsx @@ -3,7 +3,7 @@ import React, { useEffect, useRef } from 'react'; import { FlatList, FlatListProps, StyleSheet } from 'react-native'; import DropdownListItem from './DropdownListItem'; import { ItemSeparatorComponent, ListEmptyComponent } from '../Others'; -import { TFlatList } from 'src/types/index.types'; +import { TFlatList } from '../../types/index.types'; const DropdownFlatList = ({ options, @@ -31,7 +31,7 @@ const DropdownFlatList = ({ const flatlistRef = useRef>(null); const scrollToItem = (index: number) => { - flatlistRef.current?.scrollToIndex({ + flatlistRef?.current?.scrollToIndex({ index, animated: true, }); @@ -43,6 +43,12 @@ const DropdownFlatList = ({ } }, [listIndex]); + const itemSeparator = () => ( + + ); + return ( ( - - )} + ItemSeparatorComponent={itemSeparator} renderItem={(item) => _renderItem(item, { optionLabel, diff --git a/src/components/Dropdown/DropdownSectionList.tsx b/src/components/Dropdown/DropdownSectionList.tsx index 1bebab7..7e1d175 100644 --- a/src/components/Dropdown/DropdownSectionList.tsx +++ b/src/components/Dropdown/DropdownSectionList.tsx @@ -8,7 +8,7 @@ import { SectionHeaderTitle, } from '../Others'; import { extractPropertyFromArray } from '../../utils'; -import { TSectionList } from 'src/types/index.types'; +import { TSectionList } from '../../types/index.types'; const DropdownSectionList = ({ options, @@ -79,6 +79,12 @@ const DropdownSectionList = ({ } }, [listIndex]); + const itemSeparator = () => ( + + ); + return ( ( - - )} + ItemSeparatorComponent={itemSeparator} renderItem={(item) => _renderItem(item, { optionLabel, diff --git a/src/hooks/use-modal.ts b/src/hooks/use-modal.ts index bcb421c..5fa9818 100644 --- a/src/hooks/use-modal.ts +++ b/src/hooks/use-modal.ts @@ -1,50 +1,28 @@ -import { useState, useCallback, useEffect } from 'react'; -import { ModalProps, Platform } from 'react-native'; - interface UseModalProps { - hideModal: boolean; - modalProps?: ModalProps; - onDismiss?: () => void; resetOptionsRelatedState: () => void; disabled?: boolean; + modalRef: any; } export const useModal = ({ - hideModal, - onDismiss, resetOptionsRelatedState, disabled, + modalRef, }: UseModalProps) => { - const [open, setOpen] = useState(false); - - useEffect(() => { - if (hideModal) { - setOpen(false); - } - }, [hideModal]); - - useEffect(() => { - if (!open && Platform.OS === 'android') { - onDismiss?.(); - } - }, [open, onDismiss]); - - const openModal = useCallback(() => { + const openModal = () => { if (disabled) { return; } - setOpen(true); + modalRef.current?.open(); resetOptionsRelatedState(); - }, [disabled, setOpen, resetOptionsRelatedState]); + }; - const closeModal = useCallback(() => { - setOpen(false); + const closeModal = () => { + modalRef.current?.close(); resetOptionsRelatedState(); - }, [setOpen, resetOptionsRelatedState]); + }; return { - open, - setOpen, openModal, closeModal, }; diff --git a/src/hooks/use-selection-handler.ts b/src/hooks/use-selection-handler.ts index 317f8c5..89d22fb 100644 --- a/src/hooks/use-selection-handler.ts +++ b/src/hooks/use-selection-handler.ts @@ -6,7 +6,7 @@ interface UseSelectionHandlerProps { isMultiple: boolean; maxSelectableItems?: number; onValueChange: (selectedItems: TSelectedItem | TSelectedItem[]) => void; - setOpen: (value: boolean) => void; + closeModal: () => void; autoCloseOnSelect: boolean; } @@ -15,7 +15,7 @@ export const useSelectionHandler = ({ isMultiple, maxSelectableItems, onValueChange, - setOpen, + closeModal, autoCloseOnSelect, }: UseSelectionHandlerProps) => { // Initialize state based on whether it's multiple selection or not @@ -36,11 +36,11 @@ export const useSelectionHandler = ({ onValueChange(value); // Send selected value to parent if (autoCloseOnSelect) { - setOpen(false); // close modal upon selection + closeModal(); // close modal upon selection } } }, - [selectedItem, onValueChange, autoCloseOnSelect, setOpen] + [selectedItem, onValueChange, autoCloseOnSelect, closeModal] ); const handleMultipleSelections = useCallback( diff --git a/src/index.tsx b/src/index.tsx index 0a0f8d3..ae266ad 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,11 +1,11 @@ import React, { useCallback, useEffect, useRef } from 'react'; -import { TouchableOpacity, StyleSheet, View, Platform } from 'react-native'; +import { TouchableOpacity, StyleSheet, View } from 'react-native'; import Input from './components/Input'; import CheckBox from './components/CheckBox'; import Dropdown from './components/Dropdown/Dropdown'; import DropdownFlatList from './components/Dropdown/DropdownFlatList'; import DropdownSectionList from './components/Dropdown/DropdownSectionList'; -import CustomModal from './components/CustomModal'; +import CustomModal, { CustomModalHandle } from './components/CustomModal'; import { colors } from './styles/colors'; import { DEFAULT_OPTION_LABEL, DEFAULT_OPTION_VALUE } from './constants'; import type { DropdownProps, TSelectedItem } from './types/index.types'; @@ -57,7 +57,6 @@ export const DropdownSelect: React.FC = ({ listComponentStyles, listEmptyComponent, modalProps, // kept for backwards compatibility - hideModal = false, listControls, searchControls, modalControls, @@ -103,47 +102,19 @@ export const DropdownSelect: React.FC = ({ setSearchValue(''); setFilteredOptions(options); setListIndex({ itemIndex: -1, sectionIndex: -1 }); - }, [filteredOptions, setFilteredOptions, setListIndex, setSearchValue]); + }, [options, setFilteredOptions, setListIndex, setSearchValue]); /*=========================================== * Modal *==========================================*/ - const { open, setOpen, openModal, closeModal } = useModal({ - hideModal, - modalProps, - onDismiss: modalControls?.modalProps?.onDismiss, + const modalRef = useRef(null); + + const { openModal, closeModal } = useModal({ resetOptionsRelatedState, disabled, + modalRef, }); - useEffect(() => { - if (hideModal) { - setOpen(false); - } - return () => {}; - }, [hideModal, setOpen]); - - /** - * To prevent triggering on modalProps.onDismiss on first render, we perform this check - */ - const hasComponentBeenRendered = useRef(false); - - /** - * Explicitly adding this here because the onDismiss only works on iOS Modals - * https://reactnative.dev/docs/modal#ondismiss-ios - */ - useEffect(() => { - if ( - hasComponentBeenRendered.current && - !open && - Platform.OS === 'android' - ) { - modalControls?.modalProps?.onDismiss?.(); - } - - hasComponentBeenRendered.current = true; - }, [open, modalControls?.modalProps]); - /*=========================================== * Single and multiple selection Hook *==========================================*/ @@ -159,7 +130,7 @@ export const DropdownSelect: React.FC = ({ isMultiple, maxSelectableItems, onValueChange, - setOpen, + closeModal, autoCloseOnSelect, }); @@ -227,8 +198,8 @@ export const DropdownSelect: React.FC = ({ } selectedItem={selectedItem} selectedItems={selectedItems} - openModal={openModal} - closeModal={closeModal} + openModal={() => openModal()} + closeModal={() => closeModal()} labelStyle={labelStyle} dropdownIcon={dropdownIcon} dropdownStyle={dropdownStyle} @@ -247,12 +218,11 @@ export const DropdownSelect: React.FC = ({ {...rest} /> void; + }; }; -} & TCloseModal; - -type TCloseModal = { closeModal?: () => void }; +}; export type TListControls = { listHeaderComponent?: React.ReactNode; @@ -119,7 +120,9 @@ export type TListControls = { }; export type TSelectedItem = string | number | boolean; -export type TSelectedItemWithReactComponent = TSelectedItem | React.JSX.Element; +export type TSelectedItemWithReactComponent = + | TSelectedItem + | React.ReactElement; export type TFlatList = TFlatListItem[]; export type TFlatListItem = { diff --git a/src/utils/index.ts b/src/utils/index.ts index df3a602..b52cc30 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -4,7 +4,7 @@ import { TFlatListItem, TSectionList, TSelectedItemWithReactComponent, -} from 'src/types/index.types'; +} from '../types/index.types'; export const extractPropertyFromArray = (arr: any[], property: string) => { let extractedValue = arr?.map((item: any) => item[property]); From 8ae4b9e9a77790ba3be8f71d1d1248313c7de40b Mon Sep 17 00:00:00 2001 From: Azeezat Date: Wed, 11 Sep 2024 22:40:08 -0700 Subject: [PATCH 05/13] fix: removed unused variables --- src/__tests__/section-list-dropdown.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/section-list-dropdown.test.tsx b/src/__tests__/section-list-dropdown.test.tsx index ff48880..cb12583 100644 --- a/src/__tests__/section-list-dropdown.test.tsx +++ b/src/__tests__/section-list-dropdown.test.tsx @@ -147,8 +147,8 @@ describe('Initial state of component', () => { let count = 0; // if there is a disabled item in the list, expect no call - options.map(async (section, i) => { - section.data.map(async (item, j) => { + options.map(async (section) => { + section.data.map(async (item) => { const listItem = screen.getByText(item.label.toString()); expect(listItem); await user.press(listItem); From 82e2a4bce02bd147a7ae6a9a42a6b9a45ac2a2c2 Mon Sep 17 00:00:00 2001 From: Azeezat Date: Mon, 16 Sep 2024 00:21:44 -0700 Subject: [PATCH 06/13] chore: added more tests --- src/__tests__/flat-list-dropdown.test.tsx | 46 ++++++++++++++++++- src/__tests__/section-list-dropdown.test.tsx | 19 ++++++++ src/components/CustomModal/index.tsx | 1 + src/components/Dropdown/DropdownFlatList.tsx | 1 + .../Dropdown/DropdownSectionList.tsx | 1 + 5 files changed, 66 insertions(+), 2 deletions(-) diff --git a/src/__tests__/flat-list-dropdown.test.tsx b/src/__tests__/flat-list-dropdown.test.tsx index 5d52522..1d9d412 100644 --- a/src/__tests__/flat-list-dropdown.test.tsx +++ b/src/__tests__/flat-list-dropdown.test.tsx @@ -63,7 +63,6 @@ describe('Initial state of component', () => { }); test('search', async () => { - const user = userEvent.setup(); render(flatListDropdown); //open modal @@ -102,6 +101,29 @@ describe('Initial state of component', () => { await user.press(optionToTestFor); expect(mockOnValueChange).toHaveBeenCalledTimes(1); }); + + test('autoCloseOnSelect', async () => { + const flatListDropdownWithAutoClose = ( + + ); + + render(flatListDropdownWithAutoClose); + await user.press(screen.getByText(placeholder)); + + // select single option without closing the modal + await user.press(screen.getByText(options[0].name as string)); + expect(mockOnValueChange).toHaveBeenCalledTimes(1); + screen.getByTestId('react-native-input-select-flat-list'); + }); }); describe('Multiple select', () => { @@ -153,7 +175,6 @@ describe('Initial state of component', () => { }); test('select all / unselect all', async () => { - const user = userEvent.setup(); render(flatListDropdownWithMultiSelect); await user.press(screen.getByText(placeholder)); @@ -169,4 +190,25 @@ describe('Initial state of component', () => { screen.getByText('Select all'); //`Select all` should now be visible since all items in the list have been deselected }); }); + + test('auto scroll to index of selected item in flat list', async () => { + const selectedItem = options[3]; + + const flatListDropdownWithMultiSelectWithSelectedItem = ( + {}} + testID="section-list-test-id" + placeholder={placeholder} + optionLabel="name" + isMultiple + /> + ); + render(flatListDropdownWithMultiSelectWithSelectedItem); + await user.press(screen.getByTestId('dropdown-input-container')); + + const itemCount = screen.getAllByText(selectedItem.name as string); + expect(itemCount.length).toBe(2); //since the item is selected, it would show on the dropdown container hence the reason we have two items + }); }); diff --git a/src/__tests__/section-list-dropdown.test.tsx b/src/__tests__/section-list-dropdown.test.tsx index cb12583..c0e7bf9 100644 --- a/src/__tests__/section-list-dropdown.test.tsx +++ b/src/__tests__/section-list-dropdown.test.tsx @@ -180,4 +180,23 @@ describe('Initial state of component', () => { screen.getByText('Select all'); //`Select all` should now be visible since all items in the list have been deselected }); }); + + test('auto scroll to index of selected item in section list', async () => { + const selectedItem = options[0].data[2]; + + const flatListDropdownWithMultiSelectWithSelectedItem = ( + {}} + placeholder={placeholder} + isMultiple + /> + ); + render(flatListDropdownWithMultiSelectWithSelectedItem); + await user.press(screen.getByTestId('dropdown-input-container')); + + const itemCount = screen.getAllByText(selectedItem.label as string); + expect(itemCount.length).toBe(2); //since the item is selected, it would show on the dropdown container hence the reason we have two items + }); }); diff --git a/src/components/CustomModal/index.tsx b/src/components/CustomModal/index.tsx index dec4b02..2da869e 100644 --- a/src/components/CustomModal/index.tsx +++ b/src/components/CustomModal/index.tsx @@ -67,6 +67,7 @@ const CustomModal = forwardRef( return ( closeEvents()} diff --git a/src/components/Dropdown/DropdownFlatList.tsx b/src/components/Dropdown/DropdownFlatList.tsx index 485b197..ac270ca 100644 --- a/src/components/Dropdown/DropdownFlatList.tsx +++ b/src/components/Dropdown/DropdownFlatList.tsx @@ -51,6 +51,7 @@ const DropdownFlatList = ({ return ( Date: Tue, 17 Sep 2024 00:39:58 -0700 Subject: [PATCH 07/13] fix: aria-label error --- src/__tests__/flat-list-dropdown.test.tsx | 2 +- src/components/CheckBox/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/__tests__/flat-list-dropdown.test.tsx b/src/__tests__/flat-list-dropdown.test.tsx index 1d9d412..8d6f6d2 100644 --- a/src/__tests__/flat-list-dropdown.test.tsx +++ b/src/__tests__/flat-list-dropdown.test.tsx @@ -102,7 +102,7 @@ describe('Initial state of component', () => { expect(mockOnValueChange).toHaveBeenCalledTimes(1); }); - test('autoCloseOnSelect', async () => { + test('autoCloseOnSelect=false should not close modal after selection', async () => { const flatListDropdownWithAutoClose = ( onChange(!value) : null} style={[styles.checkboxContainer]} disabled={disabled} - aria-label={label} + aria-label={typeof label === 'string' ? label : ''} > Date: Tue, 17 Sep 2024 03:32:08 -0700 Subject: [PATCH 08/13] fix: updated tests --- src/__tests__/section-list-dropdown.test.tsx | 36 +++++++++++++++---- src/components/CustomModal/index.tsx | 3 +- .../{Dropdown => List}/DropdownFlatList.tsx | 2 +- .../DropdownSectionList.tsx | 2 +- src/index.tsx | 6 ++-- 5 files changed, 37 insertions(+), 12 deletions(-) rename src/components/{Dropdown => List}/DropdownFlatList.tsx (98%) rename src/components/{Dropdown => List}/DropdownSectionList.tsx (98%) diff --git a/src/__tests__/section-list-dropdown.test.tsx b/src/__tests__/section-list-dropdown.test.tsx index c0e7bf9..2c4841b 100644 --- a/src/__tests__/section-list-dropdown.test.tsx +++ b/src/__tests__/section-list-dropdown.test.tsx @@ -2,7 +2,14 @@ import React from 'react'; import DropdownSelect from '../index'; import { render, screen, userEvent } from '@testing-library/react-native'; import '@testing-library/jest-dom'; -import { TSectionList } from 'src/types/index.types'; +import { TSectionList } from '../types/index.types'; +import { extractPropertyFromArray, removeDisabledItems } from '../utils'; + +const selectAllOptions = (options: TSectionList) => { + const modifiedSectionData = extractPropertyFromArray(options, 'data')?.flat(); + let val = removeDisabledItems(modifiedSectionData); + return val.map((item) => item.label as string); +}; describe('Initial state of component', () => { beforeAll(() => { @@ -120,7 +127,7 @@ describe('Initial state of component', () => { }); }); - describe.skip('Multiple select', () => { + describe('Multiple select', () => { let mockOnValueChangeMultiSelect = jest.fn(); const sectionListDropdownWithMultiSelect = ( @@ -139,7 +146,7 @@ describe('Initial state of component', () => { /> ); - test('open modal when dropdown is clicked and select a multiple items', async () => { + test.skip('open modal when dropdown is clicked and select a multiple items', async () => { const user = userEvent.setup(); render(sectionListDropdownWithMultiSelect); await user.press(screen.getByText(placeholder)); @@ -165,19 +172,36 @@ describe('Initial state of component', () => { }); test('select all / unselect all', async () => { - render(sectionListDropdownWithMultiSelect); - await user.press(screen.getByText(placeholder)); + const { rerender } = render(sectionListDropdownWithMultiSelect); + await user.press(screen.getByTestId('dropdown-input-container')); // select all const selectAll = screen.getByText('Select all'); await user.press(selectAll); expect(mockSelectAllCallback).toHaveBeenCalledTimes(1); + //N.B There is a useEffect hook that check if all the items are actually selected hence the reason for rerendering + // Rerender the component with updated `selectedValue` prop + rerender( + + ); + // unselect all const clearAll = screen.getByText('Clear all'); //`Clear all` should now be visible since all items in the list have been selected await user.press(clearAll); expect(mockUnselectAllCallback).toHaveBeenCalledTimes(1); - screen.getByText('Select all'); //`Select all` should now be visible since all items in the list have been deselected }); }); diff --git a/src/components/CustomModal/index.tsx b/src/components/CustomModal/index.tsx index 2da869e..74ca91d 100644 --- a/src/components/CustomModal/index.tsx +++ b/src/components/CustomModal/index.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react-native/no-inline-styles */ import React, { forwardRef, PropsWithChildren, @@ -94,7 +95,7 @@ const CustomModal = forwardRef( modalControls?.modalOptionsContainerStyle || modalOptionsContainerStyle, ]} - testID="modal-body" + testID="react-native-input-select-modal-body" > {children} diff --git a/src/components/Dropdown/DropdownFlatList.tsx b/src/components/List/DropdownFlatList.tsx similarity index 98% rename from src/components/Dropdown/DropdownFlatList.tsx rename to src/components/List/DropdownFlatList.tsx index ac270ca..7851181 100644 --- a/src/components/Dropdown/DropdownFlatList.tsx +++ b/src/components/List/DropdownFlatList.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-native/no-inline-styles */ import React, { useEffect, useRef } from 'react'; import { FlatList, FlatListProps, StyleSheet } from 'react-native'; -import DropdownListItem from './DropdownListItem'; +import DropdownListItem from '../Dropdown/DropdownListItem'; import { ItemSeparatorComponent, ListEmptyComponent } from '../Others'; import { TFlatList } from '../../types/index.types'; diff --git a/src/components/Dropdown/DropdownSectionList.tsx b/src/components/List/DropdownSectionList.tsx similarity index 98% rename from src/components/Dropdown/DropdownSectionList.tsx rename to src/components/List/DropdownSectionList.tsx index 5019f01..14d16a8 100644 --- a/src/components/Dropdown/DropdownSectionList.tsx +++ b/src/components/List/DropdownSectionList.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-native/no-inline-styles */ import React, { useEffect, useState, useRef } from 'react'; import { SectionList, StyleSheet } from 'react-native'; -import DropdownListItem from './DropdownListItem'; +import DropdownListItem from '../Dropdown/DropdownListItem'; import { ItemSeparatorComponent, ListEmptyComponent, diff --git a/src/index.tsx b/src/index.tsx index ae266ad..f90ee17 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -3,8 +3,8 @@ import { TouchableOpacity, StyleSheet, View } from 'react-native'; import Input from './components/Input'; import CheckBox from './components/CheckBox'; import Dropdown from './components/Dropdown/Dropdown'; -import DropdownFlatList from './components/Dropdown/DropdownFlatList'; -import DropdownSectionList from './components/Dropdown/DropdownSectionList'; +import DropdownFlatList from './components/List/DropdownFlatList'; +import DropdownSectionList from './components/List/DropdownSectionList'; import CustomModal, { CustomModalHandle } from './components/CustomModal'; import { colors } from './styles/colors'; import { DEFAULT_OPTION_LABEL, DEFAULT_OPTION_VALUE } from './constants'; @@ -250,7 +250,7 @@ export const DropdownSelect: React.FC = ({ isMultiple && modifiedOptions?.length > 1 && ( - {}}> + {}} accessible={false}> Date: Wed, 18 Sep 2024 12:22:51 -0700 Subject: [PATCH 09/13] fix: modal refactor --- src/__tests__/empty-dropdown.test.tsx | 2 +- src/__tests__/flat-list-dropdown.test.tsx | 8 +- src/__tests__/section-list-dropdown.test.tsx | 11 +- src/components/CustomModal/index.tsx | 130 +++++++----------- src/components/Dropdown/Dropdown.tsx | 4 +- .../Dropdown/DropdownSelectedItemsView.tsx | 12 +- src/hooks/use-modal.ts | 28 ++-- src/hooks/use-search.ts | 8 +- src/index.tsx | 39 +++--- src/types/index.types.ts | 5 +- src/utils/index.ts | 4 +- 11 files changed, 116 insertions(+), 135 deletions(-) diff --git a/src/__tests__/empty-dropdown.test.tsx b/src/__tests__/empty-dropdown.test.tsx index 03d0c2c..d099f61 100644 --- a/src/__tests__/empty-dropdown.test.tsx +++ b/src/__tests__/empty-dropdown.test.tsx @@ -106,7 +106,7 @@ describe('Initial state of component', () => { test('Disabled dropdown should not be clickable', async () => { render(disabledDropdown); - let dropdownInput = screen.getByTestId('dropdown-input-container'); + let dropdownInput = screen.getByTestId('react-native-input-select-dropdown-input-container'); await user.press(dropdownInput); expect(dropdownInput.props?.accessibilityState?.disabled).toBe(true); diff --git a/src/__tests__/flat-list-dropdown.test.tsx b/src/__tests__/flat-list-dropdown.test.tsx index 8d6f6d2..4338d10 100644 --- a/src/__tests__/flat-list-dropdown.test.tsx +++ b/src/__tests__/flat-list-dropdown.test.tsx @@ -74,18 +74,18 @@ describe('Initial state of component', () => { const searchPlaceholder = 'Search anything here'; const searchBox = screen.getByPlaceholderText(searchPlaceholder); let text = 'hello'; - totalCount += text.length; await user.type(searchBox, text); + totalCount += text.length; screen.getByText('No options available'); expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount); //search existent item text = 'rice'; - totalCount += text.length; await user.clear(searchBox); await user.type(searchBox, text); + totalCount += text.length; screen.getByText(text, { exact: false }); - expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount + 1); //adding 1 because the clear event also called the search callback + expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount); }); describe('Single select', () => { @@ -206,7 +206,7 @@ describe('Initial state of component', () => { /> ); render(flatListDropdownWithMultiSelectWithSelectedItem); - await user.press(screen.getByTestId('dropdown-input-container')); + await user.press(screen.getByTestId('react-native-input-select-dropdown-input-container')); const itemCount = screen.getAllByText(selectedItem.name as string); expect(itemCount.length).toBe(2); //since the item is selected, it would show on the dropdown container hence the reason we have two items diff --git a/src/__tests__/section-list-dropdown.test.tsx b/src/__tests__/section-list-dropdown.test.tsx index 2c4841b..32a0ca9 100644 --- a/src/__tests__/section-list-dropdown.test.tsx +++ b/src/__tests__/section-list-dropdown.test.tsx @@ -96,17 +96,17 @@ describe('Initial state of component', () => { const searchPlaceholder = 'Search anything here'; const searchBox = screen.getByPlaceholderText(searchPlaceholder); let text = 'hello'; - totalCount += text.length; await user.type(searchBox, text); + totalCount += text.length; expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount); //search existent item text = 'pizza'; - totalCount += text.length; await user.clear(searchBox); await user.type(searchBox, text); + totalCount += text.length; screen.getByText(text, { exact: false }); - expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount + 1); //adding 1 because the clear event also called the search callback + expect(mockSearchCallback).toHaveBeenCalledTimes(totalCount); }); describe('Single select', () => { @@ -146,6 +146,7 @@ describe('Initial state of component', () => { /> ); + // TODO: revisit test.skip('open modal when dropdown is clicked and select a multiple items', async () => { const user = userEvent.setup(); render(sectionListDropdownWithMultiSelect); @@ -173,7 +174,7 @@ describe('Initial state of component', () => { test('select all / unselect all', async () => { const { rerender } = render(sectionListDropdownWithMultiSelect); - await user.press(screen.getByTestId('dropdown-input-container')); + await user.press(screen.getByTestId('react-native-input-select-dropdown-input-container')); // select all const selectAll = screen.getByText('Select all'); @@ -218,7 +219,7 @@ describe('Initial state of component', () => { /> ); render(flatListDropdownWithMultiSelectWithSelectedItem); - await user.press(screen.getByTestId('dropdown-input-container')); + await user.press(screen.getByTestId('react-native-input-select-dropdown-input-container')); const itemCount = screen.getAllByText(selectedItem.label as string); expect(itemCount.length).toBe(2); //since the item is selected, it would show on the dropdown container hence the reason we have two items diff --git a/src/components/CustomModal/index.tsx b/src/components/CustomModal/index.tsx index 74ca91d..837c99a 100644 --- a/src/components/CustomModal/index.tsx +++ b/src/components/CustomModal/index.tsx @@ -1,11 +1,5 @@ /* eslint-disable react-native/no-inline-styles */ -import React, { - forwardRef, - PropsWithChildren, - ReactElement, - useImperativeHandle, - useState, -} from 'react'; +import React, { PropsWithChildren, ReactElement } from 'react'; import { KeyboardAvoidingView, Modal, @@ -19,15 +13,6 @@ import { import { colors } from '../../styles/colors'; import { TCustomModalControls } from 'src/types/index.types'; -export interface CustomModalProps extends TCustomModalControls, ModalProps { - // Add other prop types if needed -} - -export interface CustomModalHandle { - open: () => void; - close: () => void; -} - // In iOS, `SafeAreaView` does not automatically account on keyboard. // Therefore, for iOS we need to wrap the content in `KeyboardAvoidingView`. const ModalContentWrapper = ({ children }: PropsWithChildren): ReactElement => { @@ -40,72 +25,53 @@ const ModalContentWrapper = ({ children }: PropsWithChildren): ReactElement => { ); }; -const CustomModal = forwardRef( - ( - { - modalBackgroundStyle, //kept for backwards compatibility - modalOptionsContainerStyle, //kept for backwards compatibility - modalControls, - modalProps, //kept for backwards compatibility - children, - }: CustomModalProps, - ref - ) => { - const [isVisible, setIsVisible] = useState(false); - - // customizes the instance value that is exposed to parent components when using ref - useImperativeHandle(ref, () => ({ - open: () => setIsVisible(true), - close: () => closeEvents(), - })); - - const closeEvents = () => { - setIsVisible(false); - modalControls?.modalProps?.closeModal?.(); //kept for backwards compatibility - modalProps?.onDismiss?.(); //kept for backwards compatibility - modalControls?.modalProps?.onDismiss?.(); - }; - - return ( - closeEvents()} - animationType="fade" - {...modalControls?.modalProps} - {...modalProps} //kept for backwards compatibility - > - {/*Used to fix the select with search box behavior in iOS*/} - - closeEvents()} - style={[ - styles.modalContainer, - styles.modalBackgroundStyle, - modalControls?.modalBackgroundStyle || modalBackgroundStyle, - ]} - aria-label="close modal" - > - {/* Added this `TouchableWithoutFeedback` wrapper because of the closing modal on expo web */} - {}}> - - {children} - - - - - - ); - } -); +const CustomModal = ({ + visible, + modalBackgroundStyle, //kept for backwards compatibility + modalOptionsContainerStyle, //kept for backwards compatibility + modalControls, + modalProps, //kept for backwards compatibility + children, + onRequestClose, +}: TCustomModalControls & ModalProps) => { + return ( + + {/*Used to fix the select with search box behavior in iOS*/} + + + {/* Added this `TouchableWithoutFeedback` wrapper because of the closing modal on expo web */} + {}} accessible={false}> + + {children} + + + + + + ); +}; const styles = StyleSheet.create({ modalContainer: { diff --git a/src/components/Dropdown/Dropdown.tsx b/src/components/Dropdown/Dropdown.tsx index 47d0d22..bc8b75a 100644 --- a/src/components/Dropdown/Dropdown.tsx +++ b/src/components/Dropdown/Dropdown.tsx @@ -10,7 +10,7 @@ const Dropdown = ({ placeholder, helperText, error, - getSelectedItemsLabel, + labelsOfSelectedItems, openModal, closeModal, isMultiple, @@ -45,7 +45,7 @@ const Dropdown = ({ true} > {isMultiple ? ( - getSelectedItemsLabel()?.map((label: string, i: Number) => ( + labelsOfSelectedItems?.map((label: string, i: Number) => ( { openModal(); setIndexOfSelectedItem(label); // immediately scrolls to list item with the specified label when modal }} - key={`react-native-input-select-${Math.random()}-${i}`} + key={`react-native-input-select-list-item-${Math.random()}-${i}`} style={[ styles.selectedItems, { backgroundColor: primaryColor }, @@ -80,10 +80,10 @@ const DropdownSelectedItemsView = ({ { openModal(); - setIndexOfSelectedItem(getSelectedItemsLabel()); // immediately scrolls to list item with the specified label when modal + setIndexOfSelectedItem(labelsOfSelectedItems); // immediately scrolls to list item with the specified label when modal }} style={[styles.blackText, selectedItemStyle]} - label={getSelectedItemsLabel()} + label={labelsOfSelectedItems} disabled={disabled} /> )} diff --git a/src/hooks/use-modal.ts b/src/hooks/use-modal.ts index 5fa9818..67c04e2 100644 --- a/src/hooks/use-modal.ts +++ b/src/hooks/use-modal.ts @@ -1,29 +1,39 @@ +import { useState } from 'react'; +import { ModalProps } from 'react-native'; +import { TCustomModalControls } from '../types/index.types'; + interface UseModalProps { resetOptionsRelatedState: () => void; disabled?: boolean; - modalRef: any; + modalProps?: ModalProps; + modalControls?: TCustomModalControls; } export const useModal = ({ resetOptionsRelatedState, disabled, - modalRef, + modalProps, + modalControls, }: UseModalProps) => { + const [isVisible, setIsVisible] = useState(false); + const openModal = () => { - if (disabled) { - return; - } - modalRef.current?.open(); + if (disabled) return; + setIsVisible(true); resetOptionsRelatedState(); }; const closeModal = () => { - modalRef.current?.close(); + setIsVisible(false); resetOptionsRelatedState(); + modalControls?.modalProps?.closeModal?.(); //kept for backwards compatibility + modalProps?.onDismiss?.(); //kept for backwards compatibility + modalControls?.modalProps?.onDismiss?.(); }; return { - openModal, - closeModal, + isVisible, + openModal: () => openModal(), + closeModal: () => closeModal(), }; }; diff --git a/src/hooks/use-search.ts b/src/hooks/use-search.ts index 49e5e33..9230d7c 100644 --- a/src/hooks/use-search.ts +++ b/src/hooks/use-search.ts @@ -59,7 +59,6 @@ export const useSearch = ({ const onSearch = useCallback( (value: string) => { - setSearchValue(value); searchCallback?.(value); const searchText = escapeRegExp(value).toLowerCase().trim(); @@ -80,12 +79,17 @@ export const useSearch = ({ ] ); + useEffect(() => { + if (searchValue) { + onSearch(searchValue); + } + }, [onSearch, searchValue]); + return { searchValue, setSearchValue, filteredOptions, setFilteredOptions, - onSearch, isSectionList: isSection, }; }; diff --git a/src/index.tsx b/src/index.tsx index f90ee17..cdc43c1 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect } from 'react'; import { TouchableOpacity, StyleSheet, View } from 'react-native'; import Input from './components/Input'; import CheckBox from './components/CheckBox'; import Dropdown from './components/Dropdown/Dropdown'; import DropdownFlatList from './components/List/DropdownFlatList'; import DropdownSectionList from './components/List/DropdownSectionList'; -import CustomModal, { CustomModalHandle } from './components/CustomModal'; +import CustomModal from './components/CustomModal'; import { colors } from './styles/colors'; import { DEFAULT_OPTION_LABEL, DEFAULT_OPTION_VALUE } from './constants'; import type { DropdownProps, TSelectedItem } from './types/index.types'; -import { extractPropertyFromArray, getSelectedItemsLabel } from './utils'; +import { extractPropertyFromArray, getLabelsOfSelectedItems } from './utils'; import { useSelectionHandler, useModal, @@ -71,7 +71,6 @@ export const DropdownSelect: React.FC = ({ const { searchValue, setSearchValue, - onSearch, setFilteredOptions, filteredOptions, isSectionList, @@ -107,12 +106,11 @@ export const DropdownSelect: React.FC = ({ /*=========================================== * Modal *==========================================*/ - const modalRef = useRef(null); - - const { openModal, closeModal } = useModal({ + const { isVisible, openModal, closeModal } = useModal({ resetOptionsRelatedState, disabled, - modalRef, + modalProps, + modalControls, }); /*=========================================== @@ -130,7 +128,7 @@ export const DropdownSelect: React.FC = ({ isMultiple, maxSelectableItems, onValueChange, - closeModal, + closeModal: () => closeModal(), autoCloseOnSelect, }); @@ -186,16 +184,14 @@ export const DropdownSelect: React.FC = ({ placeholder={placeholder} helperText={helperText} error={error} - getSelectedItemsLabel={() => - getSelectedItemsLabel({ - isMultiple, - optionLabel, - optionValue, - selectedItem, - selectedItems, - modifiedOptions, - }) - } + labelsOfSelectedItems={getLabelsOfSelectedItems({ + isMultiple, + optionLabel, + optionValue, + selectedItem, + selectedItems, + modifiedOptions, + })} selectedItem={selectedItem} selectedItems={selectedItems} openModal={() => openModal()} @@ -218,11 +214,12 @@ export const DropdownSelect: React.FC = ({ {...rest} /> closeModal()} modalBackgroundStyle={modalBackgroundStyle} // kept for backwards compatibility modalOptionsContainerStyle={modalOptionsContainerStyle} // kept for backwards compatibility modalControls={modalControls} modalProps={modalProps} // kept for backwards compatibility - ref={modalRef} > = ({ {isSearchable && ( onSearch(text)} + onChangeText={(text: string) => setSearchValue(text)} style={searchControls?.textInputStyle || searchInputStyle} primaryColor={primaryColor} textInputContainerStyle={ diff --git a/src/types/index.types.ts b/src/types/index.types.ts index c335768..75f3112 100644 --- a/src/types/index.types.ts +++ b/src/types/index.types.ts @@ -89,7 +89,10 @@ export type TCustomModalControls = { /** @deprecated Use `modalControls = {{ modalOptionsContainerStyle: ViewStyle}} instead.*/ modalOptionsContainerStyle?: ViewStyle; /** @deprecated Use `modalControls = {{modalProps: ModalProps }}` instead.*/ - modalProps?: ModalProps; + modalProps?: ModalProps & { + /** @deprecated Use `onDismiss` instead.*/ + closeModal?: () => void; + }; modalControls?: { modalBackgroundStyle?: ViewStyle; modalOptionsContainerStyle?: ViewStyle; diff --git a/src/utils/index.ts b/src/utils/index.ts index b52cc30..782e9f6 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -28,10 +28,10 @@ export const isSectionList = (options: TFlatList | TSectionList): boolean => { /** * - * @description get the labels of the items that were selected from the options array + * @description get the labels of the items that were selected from the options array for either multiple or single selections * @returns */ -export const getSelectedItemsLabel = ({ +export const getLabelsOfSelectedItems = ({ isMultiple, optionLabel, optionValue, From 0865a4e17395a343c0526b1d0bcf58e75768f919 Mon Sep 17 00:00:00 2001 From: Azeezat Date: Mon, 23 Sep 2024 01:25:13 -0700 Subject: [PATCH 10/13] feat: exposed close and open modal functions via ref and included example --- example/src/App.tsx | 25 +- package.json | 6 +- src/__tests__/empty-dropdown.test.tsx | 6 +- src/components/CheckBox/checkbox.types.ts | 4 +- src/components/CustomModal/index.tsx | 6 +- src/hooks/use-modal.ts | 13 +- src/index.tsx | 554 +++++++++++----------- src/types/index.types.ts | 93 ++-- 8 files changed, 390 insertions(+), 317 deletions(-) diff --git a/example/src/App.tsx b/example/src/App.tsx index 053bac8..483182a 100644 --- a/example/src/App.tsx +++ b/example/src/App.tsx @@ -1,5 +1,5 @@ /* eslint-disable react-native/no-inline-styles */ -import React, {useEffect, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import { SafeAreaView, ScrollView, @@ -10,9 +10,11 @@ import { Alert, Image, Pressable, + TouchableHighlight, } from 'react-native'; import DropdownSelect from 'react-native-input-select'; import {countries} from './data'; +import {DropdownSelectHandle} from '../../src/types/index.types'; export default function App() { const [user, setUser] = useState(''); @@ -40,6 +42,8 @@ export default function App() { console.log('You can make an API call when the modal opens.'); }; + const dropdownRef = useRef(null); + return ( @@ -262,7 +266,18 @@ export default function App() { fontWeight: '900', }} /> - + dropdownRef.current?.open()} + style={{ + alignSelf: 'flex-start', + backgroundColor: 'green', + marginBottom: 20, + padding: 3, + }}> + + Open the dropdown below by pressing this component + + Alert.alert('Left button pressed')} color="#007AFF" /> +