Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PlanInterval,
} from '~/generated/graphql'
import { usePlanFormSetup } from '~/hooks/plans/usePlanFormSetup'
import type { UseDebouncedSearch } from '~/hooks/useDebouncedSearch'
import type { QuoteCustomer } from '~/pages/quotes/hooks/useSubscriptionPricingDrawer'
import { render } from '~/test-utils'

Expand Down Expand Up @@ -60,8 +61,11 @@ jest.mock('@tanstack/react-virtual', () => ({
}))

jest.mock('~/hooks/useDebouncedSearch', () => ({
useDebouncedSearch: (searchQuery: unknown) => ({
debouncedSearch: searchQuery,
useDebouncedSearch: (): ReturnType<UseDebouncedSearch> => ({
debouncedSearch: Object.assign(jest.fn<void, [string]>(), {
cancel: jest.fn(),
flush: jest.fn(),
}),
isLoading: false,
}),
}))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { render as rtlRender, screen, waitFor } from '@testing-library/react'
import { fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'

import { useGetWebhookLogLazyQuery } from '~/generated/graphql'
Expand Down Expand Up @@ -142,6 +142,14 @@ describe('WebhookLogs', () => {
})
})

it('passes the typed search string to the debounce handler', () => {
renderWithParams(<WebhookLogs webhookId="webhook-123" />)

fireEvent.change(screen.getByRole('textbox'), { target: { value: 'customer.created' } })

expect(mockDebouncedSearch).toHaveBeenCalledWith('customer.created')
})

describe('GIVEN no logId in params and data has logs', () => {
describe('WHEN the component renders', () => {
it('THEN should not display log details (no logId)', () => {
Expand Down
11 changes: 4 additions & 7 deletions src/components/form/ComboBox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,13 @@ export const ComboBox = ({
}, [rawData])
const prevRawData = prevRawDataRef.current

// when `data` gets updated, make sure that if the current value is not belonging to
// a deleted option
// N.B: we compute the diff to not delete a "freeForm" value
// Only clear removed options; values that were never options may be free-form.
useEffect(() => {
if (prevRawData && data) {
const deletedOptions = prevRawData.filter(
({ value: oldVal }) => !data.find(({ value: newVal }) => oldVal === newVal),
)
const wasAnOption = prevRawData.some((option) => option.value === value)
const isStillAnOption = data.some((option) => option.value === value)

if (deletedOptions.find(({ value: deletedValue }) => value === deletedValue)) {
if (wasAnOption && !isStillAnOption) {
onChange('')
}
}
Expand Down
111 changes: 111 additions & 0 deletions src/components/form/ComboBox/__tests__/ComboBox.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { act, fireEvent, render, screen } from '@testing-library/react'

import { DEBOUNCE_SEARCH_MS } from '~/hooks/useDebouncedSearch'

import { ComboBox } from '../ComboBox'
import { BasicComboBoxData } from '../types'

jest.mock('~/hooks/core/useInternationalization', () => ({
useInternationalization: () => ({ translate: (key: string): string => key }),
}))

const options: BasicComboBoxData[] = [
{ value: 'alpha', label: 'Alpha' },
{ value: 'beta', label: 'Beta' },
]

const advanceDebounce = (): void => {
act(() => {
jest.advanceTimersByTime(DEBOUNCE_SEARCH_MS)
})
}

describe('ComboBox', () => {
beforeEach(() => {
jest.useFakeTimers()
})

afterEach(() => {
jest.useRealTimers()
})

it('clears a selected option when that option is removed', () => {
const onChange = jest.fn()
const { rerender } = render(
<ComboBox data={options} value="alpha" allowAddValue onChange={onChange} />,
)

expect(onChange).not.toHaveBeenCalled()
rerender(<ComboBox data={[options[1]]} value="alpha" allowAddValue onChange={onChange} />)
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('')
})

it('retains the selection when only another option or a duplicate is removed', () => {
const onChange = jest.fn()
const { rerender } = render(
<ComboBox data={[...options, options[0]]} value="alpha" onChange={onChange} />,
)

rerender(<ComboBox data={[options[0]]} value="alpha" onChange={onChange} />)
expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole('combobox')).toHaveValue('Alpha')
})

it('retains a free-form value when all options are removed', () => {
const onChange = jest.fn()
const { rerender } = render(
<ComboBox data={options} value="custom" allowAddValue onChange={onChange} />,
)

rerender(<ComboBox data={[]} value="custom" allowAddValue onChange={onChange} />)
expect(onChange).not.toHaveBeenCalled()
expect(screen.getByRole('combobox')).toHaveValue('custom')
})

it('filters local options and accepts a free-form value without a query', () => {
const onChange = jest.fn()

render(<ComboBox data={options} allowAddValue virtualized={false} onChange={onChange} />)
advanceDebounce()

const input = screen.getByRole('combobox')

fireEvent.change(input, { target: { value: 'Al' } })
expect(screen.getByRole('option', { name: /Alpha$/ })).toBeInTheDocument()
expect(screen.queryByRole('option', { name: /Beta$/ })).not.toBeInTheDocument()

fireEvent.change(input, { target: { value: 'custom' } })
fireEvent.keyDown(input, { key: 'Enter' })
advanceDebounce()
expect(onChange).toHaveBeenLastCalledWith('custom')
})

it('debounces typed strings and restores an unfiltered remote query on blur', () => {
const searchQuery = jest.fn()

render(
<ComboBox
data={options}
searchQuery={searchQuery}
virtualized={false}
onChange={jest.fn()}
/>,
)
expect(searchQuery).toHaveBeenCalledTimes(1)
advanceDebounce()

const input = screen.getByRole('combobox')

fireEvent.change(input, { target: { value: 'alp' } })
expect(searchQuery).toHaveBeenCalledTimes(1)
expect(screen.getByRole('option', { name: /Beta$/ })).toBeInTheDocument()
advanceDebounce()
expect(searchQuery).toHaveBeenLastCalledWith({ variables: { searchTerm: 'alp' } })

fireEvent.blur(input)
advanceDebounce()
expect(searchQuery).toHaveBeenCalledTimes(3)
expect(searchQuery).toHaveBeenLastCalledWith({ variables: { searchTerm: undefined } })
})
})
37 changes: 14 additions & 23 deletions src/core/apolloClient/cacheHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,18 @@ export const mergePaginatedCollection = (
}
}

const createPaginationKeyArgs = (additionalExclusions: string[] = []): FieldPolicy['keyArgs'] => {
const excludedArgs = new Set(['page', 'limit', 'offset', ...additionalExclusions])

return (args) => {
if (!args) return false

return Object.keys(args)
.filter((key) => !excludedArgs.has(key))
.sort((a, b) => a.localeCompare(b))
}
}

/**
* Creates a standard field policy for paginated queries.
*
Expand All @@ -48,20 +60,7 @@ export const mergePaginatedCollection = (
* ```
*/
export const createPaginatedFieldPolicy = (additionalExclusions: string[] = []): FieldPolicy => ({
keyArgs(args) {
// If no args, return false to use single shared cache entry
if (!args) return false

// Standard pagination args that should NOT affect cache key
const excludedArgs = new Set(['page', 'limit', 'offset', ...additionalExclusions])

// Return sorted array of arg keys to include in cache key
// Sorting ensures consistent cache keys regardless of argument order
// Apollo will automatically hash the values
return Object.keys(args)
.filter((key) => !excludedArgs.has(key))
.sort((a, b) => a.localeCompare(b))
},
keyArgs: createPaginationKeyArgs(additionalExclusions),
merge: mergePaginatedCollection,
})

Expand All @@ -75,15 +74,7 @@ export const createPaginatedFieldPolicy = (additionalExclusions: string[] = []):
* scroll; navigate pages with `fetchMore({ variables: { page } })`.
*/
export const createSinglePageFieldPolicy = (additionalExclusions: string[] = []): FieldPolicy => ({
keyArgs(args) {
if (!args) return false

const excludedArgs = new Set(['page', 'limit', 'offset', ...additionalExclusions])

return Object.keys(args)
.filter((key) => !excludedArgs.has(key))
.sort((a, b) => a.localeCompare(b))
},
keyArgs: createPaginationKeyArgs(additionalExclusions),
merge: (_existing, incoming) => incoming,
})

Expand Down
Loading
Loading