From 498f621e7dfda778c02130b22e3f1ccf9e303af9 Mon Sep 17 00:00:00 2001 From: Kamil Kisiela Date: Tue, 24 Sep 2024 01:24:06 -0700 Subject: [PATCH] feat: Track clients & operations filters using search params (#5616) (#5617) Co-authored-by: David Guilherme --- .../components/target/insights/Fallback.tsx | 2 +- .../components/target/insights/Filters.tsx | 4 +-- .../src/components/target/insights/List.tsx | 6 ++-- .../src/components/target/insights/Stats.tsx | 8 ++--- .../lib/hooks/use-date-range-controller.ts | 1 + .../lib/hooks/use-search-params-filters.ts | 35 +++++++++++++++++++ packages/web/app/src/lib/object.spec.ts | 27 ++++++++++++++ packages/web/app/src/lib/object.ts | 11 ++++++ .../app/src/pages/target-insights-client.tsx | 10 ++---- .../src/pages/target-insights-operation.tsx | 5 +-- .../web/app/src/pages/target-insights.tsx | 10 ++++-- 11 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 packages/web/app/src/lib/hooks/use-search-params-filters.ts create mode 100644 packages/web/app/src/lib/object.spec.ts create mode 100644 packages/web/app/src/lib/object.ts diff --git a/packages/web/app/src/components/target/insights/Fallback.tsx b/packages/web/app/src/components/target/insights/Fallback.tsx index 51b8dce8b..07e99073f 100644 --- a/packages/web/app/src/components/target/insights/Fallback.tsx +++ b/packages/web/app/src/components/target/insights/Fallback.tsx @@ -29,7 +29,7 @@ export function OperationsFallback({ No stats available yet. - There is no information available for the selected date range. + There is no information available for the selected filters. diff --git a/packages/web/app/src/components/target/insights/Filters.tsx b/packages/web/app/src/components/target/insights/Filters.tsx index 9186487b7..afa3ec7ad 100644 --- a/packages/web/app/src/components/target/insights/Filters.tsx +++ b/packages/web/app/src/components/target/insights/Filters.tsx @@ -54,7 +54,7 @@ function OperationsFilter({ } const [selectedItems, setSelectedItems] = useState(() => - selected?.length ? selected : getOperationHashes(), + getOperationHashes().filter(hash => selected?.includes(hash) ?? true), ); const onSelect = useCallback( @@ -401,7 +401,7 @@ function ClientsFilter({ } const [selectedItems, setSelectedItems] = useState(() => - selected?.length ? selected : getClientNames(), + getClientNames().filter(name => selected?.includes(name) ?? true), ); const onSelect = useCallback( diff --git a/packages/web/app/src/components/target/insights/List.tsx b/packages/web/app/src/components/target/insights/List.tsx index 04a2a2c22..58f75bb5f 100644 --- a/packages/web/app/src/components/target/insights/List.tsx +++ b/packages/web/app/src/components/target/insights/List.tsx @@ -10,6 +10,7 @@ import { env } from '@/env/frontend'; import { FragmentType, graphql, useFragment } from '@/gql'; import { DateRangeInput } from '@/gql/graphql'; import { useDecimal, useFormattedDuration, useFormattedNumber } from '@/lib/hooks'; +import { pick } from '@/lib/object'; import { ChevronUpIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { Link } from '@tanstack/react-router'; import { @@ -74,10 +75,11 @@ function OperationRow({ operationName: operation.name, operationHash: operation.hash, }} - search={{ + search={searchParams => ({ + ...pick(searchParams, ['clients']), from: selectedPeriod?.from ? encodeURIComponent(selectedPeriod.from) : undefined, to: selectedPeriod?.to ? encodeURIComponent(selectedPeriod.to) : undefined, - }} + })} > {operation.name} diff --git a/packages/web/app/src/components/target/insights/Stats.tsx b/packages/web/app/src/components/target/insights/Stats.tsx index d01fa53bd..b3335affa 100644 --- a/packages/web/app/src/components/target/insights/Stats.tsx +++ b/packages/web/app/src/components/target/insights/Stats.tsx @@ -27,6 +27,7 @@ import { useFormattedNumber, useFormattedThroughput, } from '@/lib/hooks'; +import { pick } from '@/lib/object'; import { useChartStyles } from '@/utils'; import { useRouter } from '@tanstack/react-router'; import { OperationsFallback } from './Fallback'; @@ -567,12 +568,7 @@ function ClientsStats(props: { name: ev.value, }, search(searchParams) { - if ('from' in searchParams && 'to' in searchParams) { - return { - from: searchParams.from, - to: searchParams.to, - }; - } + return pick(searchParams, ['from', 'to']); }, }); } diff --git a/packages/web/app/src/lib/hooks/use-date-range-controller.ts b/packages/web/app/src/lib/hooks/use-date-range-controller.ts index 445d2f77b..16702fb17 100644 --- a/packages/web/app/src/lib/hooks/use-date-range-controller.ts +++ b/packages/web/app/src/lib/hooks/use-date-range-controller.ts @@ -85,6 +85,7 @@ export function useDateRangeController(args: { setSelectedPreset(preset: Preset) { void router.navigate({ search: { + ...searchParams, from: preset.range.from, to: preset.range.to, }, diff --git a/packages/web/app/src/lib/hooks/use-search-params-filters.ts b/packages/web/app/src/lib/hooks/use-search-params-filters.ts new file mode 100644 index 000000000..671a09925 --- /dev/null +++ b/packages/web/app/src/lib/hooks/use-search-params-filters.ts @@ -0,0 +1,35 @@ +import { useRouter } from '@tanstack/react-router'; + +type SearchParamsFilter = string | string[]; + +export function useSearchParamsFilter( + name: string, + defaultState: TValue, +): [TValue, (value: TValue) => void] { + const router = useRouter(); + const searchParams = router.latestLocation.search as any; + + const rawSearchValue = + ((name as string) in searchParams && (searchParams[name] as string)) || null; + const searchValue = (deserializeSearchValue(rawSearchValue) ?? defaultState) as TValue; + + const setSearchValue = (value: TValue) => { + void router.navigate({ + search: { + ...searchParams, + [name]: serializeSearchValue(value), + }, + replace: true, + }); + }; + + return [searchValue, setSearchValue]; +} + +function serializeSearchValue(value: string | string[]) { + return Array.isArray(value) ? value.join(',') : value; +} + +function deserializeSearchValue(value: string | null) { + return value?.split(','); +} diff --git a/packages/web/app/src/lib/object.spec.ts b/packages/web/app/src/lib/object.spec.ts new file mode 100644 index 000000000..d96643e41 --- /dev/null +++ b/packages/web/app/src/lib/object.spec.ts @@ -0,0 +1,27 @@ +import { pick } from './object'; + +describe('object', () => { + describe('#pick', () => { + it('returns a new object with only picked keys', () => { + const input = { a: 1, b: 2, c: '1234' }; + const result = pick(input, ['a', 'c']); + expect(result).toEqual({ a: 1, c: '1234' }); + expect(result).not.toBe(input); + }); + + it('returns an empty object if no key is present on input', () => { + const input = { a: 1, b: 2, c: '1234' }; + expect(pick(input, ['d'])).toEqual({}); + }); + + it('returns an empty object if no key is passed', () => { + const input = { a: 1, b: 2, c: '1234' }; + expect(pick(input, [])).toEqual({}); + }); + + it('returns an object with only presented keys', () => { + const input = { a: 1, b: 2, c: '1234' }; + expect(pick(input, ['a', 'd'])).toEqual({ a: 1 }); + }); + }); +}); diff --git a/packages/web/app/src/lib/object.ts b/packages/web/app/src/lib/object.ts new file mode 100644 index 000000000..573d1b5e3 --- /dev/null +++ b/packages/web/app/src/lib/object.ts @@ -0,0 +1,11 @@ +export const pick = >(value: TValue, keys: string[]) => { + return keys.reduce( + (acc, key) => { + if (key in value) { + acc[key] = value[key]; + } + return acc; + }, + {} as Record, + ); +}; diff --git a/packages/web/app/src/pages/target-insights-client.tsx b/packages/web/app/src/pages/target-insights-client.tsx index acc37a4a0..1634da295 100644 --- a/packages/web/app/src/pages/target-insights-client.tsx +++ b/packages/web/app/src/pages/target-insights-client.tsx @@ -16,6 +16,7 @@ import { CHART_PRIMARY_COLOR } from '@/constants'; import { graphql } from '@/gql'; import { formatNumber, formatThroughput, toDecimal } from '@/lib/hooks'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; +import { pick } from '@/lib/object'; import { useChartStyles } from '@/utils'; import { Link } from '@tanstack/react-router'; @@ -280,14 +281,7 @@ function ClientView(props: { operationName: operation.name, operationHash: operation.operationHash ?? '_', }} - search={searchParams => { - if ('from' in searchParams && 'to' in searchParams) { - return { - from: searchParams.from, - to: searchParams.to, - }; - } - }} + search={searchParams => pick(searchParams, ['from', 'to'])} > {operation.name} diff --git a/packages/web/app/src/pages/target-insights-operation.tsx b/packages/web/app/src/pages/target-insights-operation.tsx index b489675ed..8f96e23b5 100644 --- a/packages/web/app/src/pages/target-insights-operation.tsx +++ b/packages/web/app/src/pages/target-insights-operation.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useMemo, useState } from 'react'; +import { ReactElement, useMemo } from 'react'; import { AlertCircleIcon, RefreshCw } from 'lucide-react'; import { useQuery } from 'urql'; import { Section } from '@/components/common'; @@ -16,6 +16,7 @@ import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { FragmentType, graphql, useFragment } from '@/gql'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; +import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters'; const GraphQLOperationBody_OperationFragment = graphql(` fragment GraphQLOperationBody_OperationFragment on Operation { @@ -69,7 +70,7 @@ function OperationView({ dataRetentionInDays, defaultPreset: presetLast1Day, }); - const [selectedClients, setSelectedClients] = useState([]); + const [selectedClients, setSelectedClients] = useSearchParamsFilter('clients', []); const operationsList = useMemo(() => [operationHash], [operationHash]); const [result] = useQuery({ diff --git a/packages/web/app/src/pages/target-insights.tsx b/packages/web/app/src/pages/target-insights.tsx index 4358ee7b0..d328b20d4 100644 --- a/packages/web/app/src/pages/target-insights.tsx +++ b/packages/web/app/src/pages/target-insights.tsx @@ -1,4 +1,4 @@ -import { ReactElement, useState } from 'react'; +import { ReactElement } from 'react'; import { RefreshCw } from 'lucide-react'; import { useQuery } from 'urql'; import { Page, TargetLayout } from '@/components/layouts/target'; @@ -16,6 +16,7 @@ import { Subtitle, Title } from '@/components/ui/page'; import { QueryError } from '@/components/ui/query-error'; import { graphql } from '@/gql'; import { useDateRangeController } from '@/lib/hooks/use-date-range-controller'; +import { useSearchParamsFilter } from '@/lib/hooks/use-search-params-filters'; function OperationsView({ organizationCleanId, @@ -28,8 +29,11 @@ function OperationsView({ targetCleanId: string; dataRetentionInDays: number; }): ReactElement { - const [selectedOperations, setSelectedOperations] = useState([]); - const [selectedClients, setSelectedClients] = useState([]); + const [selectedOperations, setSelectedOperations] = useSearchParamsFilter( + 'operations', + [], + ); + const [selectedClients, setSelectedClients] = useSearchParamsFilter('clients', []); const dateRangeController = useDateRangeController({ dataRetentionInDays, defaultPreset: presetLast7Days,