diff --git a/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.test.ts b/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.test.ts index 221e747363ba..0b35414e0e2c 100644 --- a/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.test.ts +++ b/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.test.ts @@ -1,11 +1,14 @@ import { expectLogic } from 'kea-test-utils' +import { sessionRecordingSavedFiltersLogic } from 'scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic' + import { initKeaTests } from '~/test/init' import { AnyPropertyFilter, FilterLogicalOperator, PropertyFilterType, PropertyOperator, + SessionRecordingPlaylistType, UniversalFiltersGroup, } from '~/types' @@ -377,4 +380,42 @@ describe('universalFiltersLogic', () => { }) }) }) + + describe('addGroupFilter with a saved replay filter', () => { + const savedFilter = { + short_id: 'abc123', + name: 'Rage clicks', + filters: { date_from: '-7d' }, + } as unknown as SessionRecordingPlaylistType + const savedFiltersGroup = { type: TaxonomicFilterGroupType.ReplaySavedFilters } as TaxonomicFilterGroup + + beforeEach(() => { + sessionRecordingSavedFiltersLogic.mount() + }) + + it('applies the saved filter the Saved filters list hands back', async () => { + await expectLogic(sessionRecordingSavedFiltersLogic, () => { + logic.actions.addGroupFilter(savedFiltersGroup, 'abc123', savedFilter) + }).toDispatchActions([ + sessionRecordingSavedFiltersLogic.actionCreators.requestApplySavedFilter(savedFilter), + ]) + }) + + it('asks for a Recent row by short id, because the row holds no filters', async () => { + const recentRow = { + name: 'Rage clicks', + _recentContext: { + sourceGroupType: TaxonomicFilterGroupType.ReplaySavedFilters, + sourceGroupName: 'Saved filters', + sourceValue: 'abc123', + }, + } + + await expectLogic(sessionRecordingSavedFiltersLogic, () => { + logic.actions.addGroupFilter(savedFiltersGroup, 'abc123', recentRow) + }).toDispatchActions([ + sessionRecordingSavedFiltersLogic.actionCreators.requestApplySavedFilterByShortId('abc123'), + ]) + }) + }) }) diff --git a/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.ts b/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.ts index 7076f8409288..c934c332cf6c 100644 --- a/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.ts +++ b/frontend/src/lib/components/UniversalFilters/universalFiltersLogic.ts @@ -39,9 +39,11 @@ import { } from '../TaxonomicFilter/types' import { DEFAULT_UNIVERSAL_GROUP_FILTER } from './constants' -function isApplicableSavedFilter( - item: unknown -): item is SessionRecordingPlaylistType & { filters: NonNullable } { +type ApplicableSavedFilter = SessionRecordingPlaylistType & { + filters: NonNullable +} + +function isApplicableSavedFilter(item: unknown): item is ApplicableSavedFilter { return typeof item === 'object' && item !== null && 'short_id' in item && 'filters' in item && item.filters != null } @@ -260,8 +262,14 @@ export const universalFiltersLogic = kea([ addGroupFilter: ({ taxonomicGroup, propertyKey, item }) => { if (taxonomicGroup.type === TaxonomicFilterGroupType.ReplaySavedFilters) { + const savedFiltersLogic = sessionRecordingSavedFiltersLogic.findMounted() if (isApplicableSavedFilter(item)) { - sessionRecordingSavedFiltersLogic.findMounted()?.actions.requestApplySavedFilter(item) + savedFiltersLogic?.actions.requestApplySavedFilter(item) + } else if (propertyKey) { + // A row from the Recent category is a stored summary of a saved filter: it holds + // the short id the row is keyed by and never the filters, which are too heavy to + // keep in local storage. The replay logic resolves the short id back to a filter. + savedFiltersLogic?.actions.requestApplySavedFilterByShortId(String(propertyKey)) } return } diff --git a/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.test.ts b/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.test.ts index 850c23e718e5..06975bdb4822 100644 --- a/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.test.ts +++ b/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.test.ts @@ -1,6 +1,7 @@ import { router } from 'kea-router' import { expectLogic } from 'kea-test-utils' +import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { removeProjectIdIfPresent } from 'lib/utils/kea-router' import { sessionRecordingSavedFiltersLogic } from 'scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic' import { playlistFiltersLogic } from 'scenes/session-recordings/playlist/playlistFiltersLogic' @@ -13,6 +14,7 @@ import { ReplayTabs } from '~/types' describe('sessionRecordingSavedFiltersLogic', () => { let logic: ReturnType let savedFiltersRequestCount: number + let savedFilterRequestCount: number const savedFilter = { id: 'abc', short_id: 'short_abc', @@ -23,13 +25,17 @@ describe('sessionRecordingSavedFiltersLogic', () => { beforeEach(() => { savedFiltersRequestCount = 0 + savedFilterRequestCount = 0 useMocks({ get: { '/api/projects/:team/session_recording_playlists': () => { savedFiltersRequestCount += 1 return { results: [], count: 0 } }, - '/api/projects/:team/session_recording_playlists/:id': savedFilter, + '/api/projects/:team/session_recording_playlists/:id': () => { + savedFilterRequestCount += 1 + return savedFilter + }, }, }) initKeaTests() @@ -87,6 +93,57 @@ describe('sessionRecordingSavedFiltersLogic', () => { expect(removeProjectIdIfPresent(router.values.location.pathname)).toBe(urls.replay()) }) + describe('requestApplySavedFilterByShortId', () => { + it('applies a saved filter the loaded page holds, without fetching it again', async () => { + useMocks({ + get: { + '/api/projects/:team/session_recording_playlists': () => ({ + results: [savedFilter], + count: 1, + }), + }, + }) + logic.mount() + logic.actions.loadSavedFilters() + await expectLogic(logic).toFinishAllListeners() + + await expectLogic(logic, () => { + logic.actions.requestApplySavedFilterByShortId(savedFilter.short_id) + }) + .toFinishAllListeners() + .toMatchValues({ pendingFilterApplication: savedFilter }) + expect(savedFilterRequestCount).toBe(0) + }) + + it('fetches a saved filter the loaded page does not hold', async () => { + logic.mount() + + await expectLogic(logic, () => { + logic.actions.requestApplySavedFilterByShortId(savedFilter.short_id) + }) + .toFinishAllListeners() + .toMatchValues({ pendingFilterApplication: savedFilter }) + expect(savedFilterRequestCount).toBe(1) + }) + + it('reports a short id that resolves to no saved filter', async () => { + const errorToast = jest.spyOn(lemonToast, 'error').mockReturnValue('' as any) + useMocks({ + get: { + '/api/projects/:team/session_recording_playlists/:id': () => [404, { detail: 'Not found.' }], + }, + }) + logic.mount() + + await expectLogic(logic, () => { + logic.actions.requestApplySavedFilterByShortId('gone') + }) + .toFinishAllListeners() + .toMatchValues({ pendingFilterApplication: null }) + expect(errorToast).toHaveBeenCalled() + }) + }) + it('does not redirect a saved filter load that resolves after the user navigated away', async () => { router.actions.push(urls.replay(ReplayTabs.Home), { savedFilterId: savedFilter.short_id }) diff --git a/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.ts b/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.ts index c4f3b0beae0d..c97b59a13a0b 100644 --- a/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.ts +++ b/frontend/src/scenes/session-recordings/filters/sessionRecordingSavedFiltersLogic.ts @@ -3,6 +3,7 @@ import { loaders } from 'kea-loaders' import { router } from 'kea-router' import api from 'lib/api' +import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { PaginationManual } from 'lib/lemon-ui/PaginationControl' import { removeProjectIdIfPresent } from 'lib/utils/kea-router' import { objectClean } from 'lib/utils/objects' @@ -102,6 +103,9 @@ export interface sessionRecordingSavedFiltersLogicActions { requestApplySavedFilter: (filter: SessionRecordingPlaylistType) => { filter: SessionRecordingPlaylistType } + requestApplySavedFilterByShortId: (shortId: SessionRecordingPlaylistType['short_id']) => { + shortId: SessionRecordingPlaylistType['short_id'] + } setAppliedSavedFilter: (appliedSavedFilter: SessionRecordingPlaylistType | null) => { appliedSavedFilter: SessionRecordingPlaylistType | null } @@ -186,6 +190,7 @@ export const sessionRecordingSavedFiltersLogic = kea ({ appliedSavedFilter }), requestApplySavedFilter: (filter: SessionRecordingPlaylistType) => ({ filter }), + requestApplySavedFilterByShortId: (shortId: SessionRecordingPlaylistType['short_id']) => ({ shortId }), clearPendingFilterApplication: true, })), reducers(() => ({ @@ -275,6 +280,21 @@ export const sessionRecordingSavedFiltersLogic = kea ({ + requestApplySavedFilterByShortId: async ({ shortId }) => { + const loadedFilter = values.savedFilters.results.find((savedFilter) => savedFilter.short_id === shortId) + if (loadedFilter?.filters) { + actions.requestApplySavedFilter(loadedFilter) + return + } + // `savedFilters` holds one page of the list, narrowed by the search and the created-by + // filter of the saved filters panel, so a filter the picker offers can be absent from it. + const fetchedFilter = await api.recordings.getPlaylist(shortId).catch(() => null) + if (fetchedFilter?.filters) { + actions.requestApplySavedFilter(fetchedFilter) + return + } + lemonToast.error('Could not apply that saved filter. It may have been deleted.') + }, setIsFiltersExpanded: ({ isFiltersExpanded }) => { if (isFiltersExpanded) { actions.loadSavedFiltersIfNeeded()