Skip to content
Merged
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 @@ -87,6 +87,26 @@ describe("adaptSdkToMetrics", () => {
expect(metricsFilters.order?.date_field).toBe("updated_at")
})

// The default range is anchored to the current time, so two calls one second
// apart disagree. `useResourceList` deep-compares `metricsQuery` and refetches
// from page 1 when it differs, which is why `useResourceFilters` computes this
// filter once per mount instead of on every render.
test("Should move the default date range as the clock advances", () => {
const args = {
sdkFilters: {},
resourceType: "orders",
instructions,
} as const

const before = adaptSdkToMetrics(args)
vi.advanceTimersByTime(1000)
const after = adaptSdkToMetrics(args)

expect(before.order?.date_to).toBe("2023-04-05T15:20:00Z")
expect(after.order?.date_to).toBe("2023-04-05T15:20:01Z")
expect(after).not.toStrictEqual(before)
})

test("Should set a default 5-year date range when text search is defined", () => {
const metricsFilters = adaptSdkToMetrics({
sdkFilters: {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { render, waitFor } from "@testing-library/react"
import { act, type FC } from "react"
import { CoreSdkProvider } from "#providers/CoreSdkProvider"
import { MockTokenProvider as TokenProvider } from "#providers/TokenProvider/MockTokenProvider"
import { instructions } from "./mockedInstructions"
import { useResourceFilters } from "./useResourceFilters"

/**
* `FilteredList` and `FilteredTable` are memoized on the `sdkFilters` object, so
* a fresh identity is a fresh component type and React remounts the whole list.
* The query string carries more than filters, and an unrelated parameter must
* not cost a remount.
*/
describe("useResourceFilters", () => {
let renders: Array<{
sdkFilters: unknown
FilteredList: unknown
FilteredTable: unknown
}> = []

const Harness: FC = () => {
const { sdkFilters, FilteredList, FilteredTable } = useResourceFilters({
instructions,
})
renders.push({ sdkFilters, FilteredList, FilteredTable })
return <div>{sdkFilters == null ? "pending" : "ready"}</div>
}

// wouter patches `history.pushState` and dispatches an event, so navigating
// this way is what a real url change looks like to the hook
const navigate = (search: string): void => {
act(() => {
window.history.pushState({}, "", search)
})
}

const renderHarness = async (search: string): Promise<void> => {
window.history.pushState({}, "", search)
render(
<TokenProvider kind="integration" appSlug="orders" devMode>
<CoreSdkProvider>
<Harness />
</CoreSdkProvider>
</TokenProvider>,
)
await waitFor(() => {
expect(renders.at(-1)?.sdkFilters).not.toBeUndefined()
})
}

beforeEach(() => {
renders = []
// jsdom keeps a single location per test file, so a test that navigated
// would otherwise leak its query string into the next one
window.history.pushState({}, "", "/")
})

test("keeps the memoized list components when an unrelated query param changes", async () => {
await renderHarness("/?status_in=placed")
const before = renders.at(-1)
const renderCountBefore = renders.length

navigate("/?status_in=placed&page=2")

await waitFor(() => {
expect(renders.length).toBeGreaterThan(renderCountBefore)
})

const after = renders.at(-1)
expect(after?.sdkFilters).toBe(before?.sdkFilters)
expect(after?.FilteredList).toBe(before?.FilteredList)
expect(after?.FilteredTable).toBe(before?.FilteredTable)
})

test("rebuilds the memoized list components when the filters really change", async () => {
await renderHarness("/?status_in=placed")
const before = renders.at(-1)

navigate("/?status_in=approved")

await waitFor(() => {
expect(renders.at(-1)?.sdkFilters).not.toBe(before?.sdkFilters)
})

const after = renders.at(-1)
expect(after?.FilteredList).not.toBe(before?.FilteredList)
expect(after?.FilteredTable).not.toBe(before?.FilteredTable)
})
})
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ListableResourceType, QueryFilter } from "@commercelayer/sdk"
import isEqual from "lodash-es/isEqual"
import {
type JSX,
useCallback,
Expand Down Expand Up @@ -290,11 +291,19 @@ export function useResourceFilters({

useEffect(
function updateSdkQueryFilterOnSearchChange() {
setSdkFilters(
adapters.adaptUrlQueryToSdk({
queryString,
timezone: user?.timezone,
}),
const nextSdkFilters = adapters.adaptUrlQueryToSdk({
queryString,
timezone: user?.timezone,
})
// `FilteredList` and `FilteredTable` are memoized on this object, so a new
// identity is a new component type and React remounts the whole list.
// The query string carries more than filters (a view title, a page), and
// those must not blank the rows: keep the previous object whenever the
// filters it encodes are unchanged, which makes React skip the update.
setSdkFilters((currentSdkFilters) =>
isEqual(currentSdkFilters, nextSdkFilters)
? currentSdkFilters
: nextSdkFilters,
)
},
[queryString],
Expand Down Expand Up @@ -361,13 +370,46 @@ function ResourceListComponent<TResource extends ListableResourceType>({
)
}

/**
* Metrics filter for the current `sdkFilters`, computed once per mount.
*
* It must not be rebuilt on every render: with no explicit date filter,
* `adaptSdkToMetrics` falls back to a range anchored to `new Date()` (truncated
* to the second). `useResourceList` deep-compares `{ query, metricsQuery }` and
* refetches from page 1 when it differs, so an unmemoized filter turns any
* re-render landing in a later second — opening the filters drawer, switching
* tab — into a full reload of the list.
*/
function useMetricsFilter<TResource extends ListableResourceType>({
adapters,
sdkFilters,
type,
}: {
adapters: ReturnType<typeof makeFilterAdapters>
sdkFilters: QueryFilter | undefined
type: TResource
}): ReturnType<ReturnType<typeof makeFilterAdapters>["adaptSdkToMetrics"]> {
// Both call sites bail out before rendering when `sdkFilters` is undefined, so
// the value computed for that case is never sent; an empty object keeps the
// hook unconditional and its return type free of a null nobody has to handle.
return useMemo(
() =>
adapters.adaptSdkToMetrics({
sdkFilters: sdkFilters ?? {},
resourceType: type,
}),
[sdkFilters, type],
)
}

const makeFilteredList: (options: {
sdkFilters: QueryFilter | undefined
adapters: ReturnType<typeof makeFilterAdapters>
}) => UseResourceFiltersHook["FilteredList"] =
({ sdkFilters, adapters }) =>
({ type, query, metricsQuery, hideTitle, ...resourceListProps }) => {
const { t } = useTranslation()
const metricsFilter = useMetricsFilter({ adapters, sdkFilters, type })

if (resourceListProps == null) {
return <div>resourceListProps not defined</div>
Expand All @@ -394,10 +436,7 @@ const makeFilteredList: (options: {
? undefined
: {
...metricsQuery,
filter: adapters.adaptSdkToMetrics({
sdkFilters,
resourceType: type,
}),
filter: metricsFilter,
}
}
/>
Expand Down Expand Up @@ -450,6 +489,7 @@ const makeFilteredTable: (options: {
({ sdkFilters, adapters }) =>
({ type, query, metricsQuery, hideTitle, ...tableProps }) => {
const { t } = useTranslation()
const metricsFilter = useMetricsFilter({ adapters, sdkFilters, type })

if (sdkFilters == null) {
return null
Expand All @@ -471,10 +511,7 @@ const makeFilteredTable: (options: {
? undefined
: {
...metricsQuery,
filter: adapters.adaptSdkToMetrics({
sdkFilters,
resourceType: type,
}),
filter: metricsFilter,
}
}
/>
Expand Down
Loading
Loading