Skip to content

Commit

Permalink
Merge pull request #150 from osstotalsoft/feature/fixLazyLoadingOnAut…
Browse files Browse the repository at this point in the history
…ocomplete

Feature/fix lazy loading on autocomplete
  • Loading branch information
andra-sava authored Dec 18, 2024
2 parents daf886e + 8226fe3 commit 30619c3
Show file tree
Hide file tree
Showing 18 changed files with 89 additions and 61 deletions.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@
"react-number-format": "^4.9.2",
"react-router": "^7.0.1",
"react-toastify": "^10.0.6",
"ts-toolbelt": "^9.6.0"
"ts-toolbelt": "^9.6.0",
"uuid": "^11.0.3"
},
"devDependencies": {
"@babel/core": "^7.26.0",
Expand Down
44 changes: 31 additions & 13 deletions src/components/inputs/Autocomplete/Autocomplete.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ describe('Single-value Autocomplete', () => {
act(() => userClick(screen.getByText('Add "new"')))

await waitFor(() => {
expect(mockOnChange).toBeCalledWith('new', expect.anything(), 'createOption', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith('new', expect.anything(), 'createOption', expect.anything())
})
})
})
Expand Down Expand Up @@ -166,7 +166,7 @@ describe('Single-value Autocomplete', () => {
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))

expect(mockOnChange).toBeCalledWith(basicOptions[0], expect.anything(), 'selectOption', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith(basicOptions[0], expect.anything(), 'selectOption', expect.anything())
})

test('can display a value that is number', () => {
Expand All @@ -187,7 +187,7 @@ describe('Single-value Autocomplete', () => {
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))

expect(mockOnChange).toBeCalledWith(basicOptions[0], expect.anything(), 'selectOption', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith(basicOptions[0], expect.anything(), 'selectOption', expect.anything())
})
})

Expand All @@ -203,7 +203,7 @@ describe('Single-value Autocomplete', () => {
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))

expect(mockOnChange).toBeCalledWith('first option', expect.anything(), 'selectOption', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith('first option', expect.anything(), 'selectOption', expect.anything())
})

test('displays selected option in input for numeric options', () => {
Expand All @@ -216,7 +216,7 @@ describe('Single-value Autocomplete', () => {
render(<Autocomplete open options={numericOptions} onChange={mockOnChange} />)
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))
expect(mockOnChange).toBeCalledWith(1, expect.anything(), 'selectOption', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith(1, expect.anything(), 'selectOption', expect.anything())
})

test('does not show "Add" option for the one that already exist with numeric options', () => {
Expand Down Expand Up @@ -262,7 +262,7 @@ describe('Multi-value Autocomplete', () => {
act(() => userClick(screen.getByTitle('Clear')))

await waitFor(() => {
expect(mockOnChange).toBeCalledWith([basicOptions[1].id], expect.anything(), 'clear', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith([basicOptions[1].id], expect.anything(), 'clear', expect.anything())
})
})

Expand All @@ -279,7 +279,7 @@ describe('Multi-value Autocomplete', () => {
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))

expect(mockOnChange).toBeCalledWith([basicOptions[0]], expect.anything(), 'selectOption', expect.anything())
expect(mockOnChange).toHaveBeenCalledWith([basicOptions[0]], expect.anything(), 'selectOption', expect.anything())
})
})

Expand Down Expand Up @@ -307,7 +307,7 @@ describe('Multi-value Autocomplete', () => {
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))

expect(mockOnChange).toBeCalledWith(
expect(mockOnChange).toHaveBeenCalledWith(
[{ id: 1, name: 'first option', displayName: 'First Option' }],
expect.anything(),
'selectOption',
Expand All @@ -323,9 +323,9 @@ describe('Async Autocomplete', () => {
const mockLoadOptions = jest.fn(() => promise)
render(<Autocomplete loadOptions={mockLoadOptions} onChange={jest.fn()} />)

expect(mockLoadOptions).not.toBeCalled()
expect(mockLoadOptions).not.toHaveBeenCalled()
act(() => userClick(screen.getByTitle('Open')))
expect(mockLoadOptions).toBeCalled()
expect(mockLoadOptions).toHaveBeenCalled()
})

// not yet implemented
Expand All @@ -334,7 +334,7 @@ describe('Async Autocomplete', () => {
const mockLoadOptions = jest.fn(() => promise)
render(<Autocomplete loadOptions={mockLoadOptions} value={1} onChange={jest.fn()} />)

await waitFor(() => expect(mockLoadOptions).toBeCalled())
await waitFor(() => expect(mockLoadOptions).toHaveBeenCalled())
})

test('has loading state', async () => {
Expand All @@ -353,7 +353,7 @@ describe('Async Autocomplete', () => {
render(<Autocomplete loadOptions={mockLoadOptions} value={'first option'} onChange={jest.fn()} isPaginated />)

fireEvent.click(screen.getByRole('button'))
expect(mockLoadOptions).toBeCalledWith('first option', [], null)
expect(mockLoadOptions).toHaveBeenCalledWith('first option', [], null)
expect(mockLoadOptions.mock.calls[0]).toHaveLength(3)
})

Expand All @@ -371,8 +371,26 @@ describe('Async Autocomplete', () => {
render(<Autocomplete loadOptions={mockLoadOptions} value={basicOptions[0]} onChange={jest.fn()} />)

fireEvent.click(screen.getByRole('button'))
expect(mockLoadOptions).toBeCalledWith('first option', expect.anything(), null)
expect(mockLoadOptions).toHaveBeenCalledWith('first option', expect.anything(), null)
expect(mockLoadOptions.mock.calls[0]).toHaveLength(3)
})
test('loadOptions should be called only once, even if Autocomplete was opened multiple times', async () => {
const promise = Promise.resolve(basicOptions)
const mockLoadOptions = jest.fn(() => promise)
render(<Autocomplete loadOptions={mockLoadOptions} onChange={jest.fn()} />)

// open the Autocomplete
act(() => userClick(screen.getByTitle('Open')))
await waitFor(() => expect(screen.getAllByRole('option')).toHaveLength(3))

// change the input value
const options = screen.getAllByRole('option')
act(() => userClick(options[0]))

// open the Autocomplete again
act(() => userClick(screen.getByTitle('Open')))

await waitFor(() => expect(mockLoadOptions).toHaveBeenCalledTimes(1))
})
})
})
93 changes: 46 additions & 47 deletions src/components/inputs/Autocomplete/Autocomplete.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import PropTypes from 'prop-types'
import {
AutocompleteChangeDetails,
Expand All @@ -16,13 +16,14 @@ import {
Autocomplete as MuiAutocomplete,
TextField
} from '@mui/material'
import { both, concat, eqBy, has, identity, map, prop } from 'ramda'
import { both, concat, eqBy, has, identity, includes, isEmpty, map, prop } from 'ramda'
import { convertValueToOption, extractFirstValue, internalLabel, internalValue } from './utils'
import Option from './Option'
import { useTrackVisibility } from 'react-intersection-observer-hook'
import { AutocompleteProps, LoadOptionsPaginatedResult } from './types'
import LinearProgress from '../../feedback/LinearProgress'
import { emptyArray, emptyString } from '../../utils/constants'
import { v7 } from 'uuid'
const baseFilter = createFilterOptions()

const Autocomplete: React.FC<
Expand Down Expand Up @@ -66,7 +67,7 @@ const Autocomplete: React.FC<
* Handle the internal options to aid lazy loading.
*/
const [internalOptions, setInternalOptions] = useState<readonly unknown[]>(emptyArray)
const allOptions = concat(options, internalOptions)
const allOptions = useMemo(() => concat(options, internalOptions), [internalOptions, options])

/**
* Handle get option value.
Expand Down Expand Up @@ -109,30 +110,63 @@ const Autocomplete: React.FC<
const [loadMore, setLoadMore] = useState(false)
const [nextPageData, setNextPageData] = useState(null)

const refGuid = useRef(null)

const handleLoadOptions = useCallback(
(callId: string | null) => {
if (refGuid.current !== callId) {
return
}
loadOptions(internalInputValue, allOptions, nextPageData)
.then((result: readonly unknown[] | LoadOptionsPaginatedResult<unknown>) => {
const newOptions = isPaginated
? (result as LoadOptionsPaginatedResult<unknown>)?.loadedOptions
: (result as readonly unknown[])
const hasMoreData = isPaginated ? (result as LoadOptionsPaginatedResult<unknown>)?.more : false
const nextPageData = isPaginated ? (result as LoadOptionsPaginatedResult<unknown>)?.additional : null
setInternalOptions((oldOptions: readonly unknown[]) => concat(oldOptions, newOptions))
setLoadMore(hasMoreData)
setNextPageData(nextPageData)
})
.catch(error => {
console.error(error)
})
},
[allOptions, internalInputValue, isPaginated, loadOptions, nextPageData]
)

useEffect(() => {
if (isVisible) {
setInternalLoading(true)
const callId = v7()
refGuid.current = callId
handleLoadOptions(callId)
}

//TODO: Fix the exhaustive-deps rule
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isVisible])

const handleOpen = useCallback(
(event: React.SyntheticEvent) => {
if (onOpen) onOpen(event)
setInternalOpen(true)
if (loadOptions) {
if (loadOptions && isEmpty(internalOptions)) {
const callId = v7()
refGuid.current = callId
handleLoadOptions(callId)
setInternalLoading(true)
}
},
[loadOptions, onOpen]
[handleLoadOptions, internalOptions, loadOptions, onOpen]
)

const handleClose = useCallback(
(event: React.SyntheticEvent, reason: AutocompleteCloseReason) => {
if (onClose) onClose(event, reason)
setInternalOpen(false)
if (loadOptions) {
setInternalLoading(false)
setInternalInputValue(emptyString)
setInternalOptions(emptyArray)
setLoadMore(false)
setNextPageData(null)
}
Expand All @@ -143,54 +177,19 @@ const Autocomplete: React.FC<
(event: React.SyntheticEvent, value: string, reason: AutocompleteInputChangeReason) => {
if (onInputChange) onInputChange(event, value, reason)
setInternalInputValue(value)
if (reason === 'reset') return
if (includes(reason, ['reset', 'selectOption'])) return
if (loadOptions) {
const callId = v7()
refGuid.current = callId
handleLoadOptions(callId)
setInternalOptions(emptyArray)
setInternalLoading(true)
setLoadMore(false)
setNextPageData(null)
}
},
[loadOptions, onInputChange]
[handleLoadOptions, loadOptions, onInputChange]
)

useEffect(() => {
if (!internalLoading) {
return
}

let cancellationRequested = false
loadOptions(internalInputValue, allOptions, nextPageData)
.then((result: readonly unknown[] | LoadOptionsPaginatedResult<unknown>) => {
if (cancellationRequested) {
return
}

const newOptions = isPaginated
? (result as LoadOptionsPaginatedResult<unknown>)?.loadedOptions
: (result as readonly unknown[])
const hasMoreData = isPaginated ? (result as LoadOptionsPaginatedResult<unknown>)?.more : false
const nextPageData = isPaginated ? (result as LoadOptionsPaginatedResult<unknown>)?.additional : null
setInternalOptions((oldOptions: readonly unknown[]) => concat(oldOptions, newOptions))
setLoadMore(hasMoreData)
setNextPageData(nextPageData)
})
.catch(error => {
console.error(error)
})
.finally(() => {
if (cancellationRequested) {
return
}

setInternalLoading(false)
})

return () => {
cancellationRequested = true
}
}, [allOptions, internalInputValue, internalLoading, isPaginated, loadOptions, nextPageData])

const handleRenderOption = useCallback(
/**
* props: React.HTMLAttributes<HTMLLIElement> & { key: any },
Expand Down
10 changes: 10 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2594,6 +2594,7 @@ __metadata:
tsconfig-paths-webpack-plugin: "npm:^4.1.0"
tss-react: "npm:^4.9.13"
typescript: "npm:5.6.3"
uuid: "npm:^11.0.3"
languageName: unknown
linkType: soft

Expand Down Expand Up @@ -10342,6 +10343,15 @@ __metadata:
languageName: node
linkType: hard

"uuid@npm:^11.0.3":
version: 11.0.3
resolution: "uuid@npm:11.0.3"
bin:
uuid: dist/esm/bin/uuid
checksum: 10c0/cee762fc76d949a2ff9205770334699e0043d52bb766472593a25f150077c9deed821230251ea3d6ab3943a5ea137d2826678797f1d5f6754c7ce5ce27e9f7a6
languageName: node
linkType: hard

"uuid@npm:^9.0.0":
version: 9.0.1
resolution: "uuid@npm:9.0.1"
Expand Down

0 comments on commit 30619c3

Please sign in to comment.