diff --git a/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--dark.png b/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--dark.png index bb83378fac03b..f4b13980e89ec 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--dark.png and b/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--dark.png differ diff --git a/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--light.png b/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--light.png index 04eb17dbc5ec1..5f524b5f582c5 100644 Binary files a/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--light.png and b/frontend/__snapshots__/lemon-ui-lemon-collapse--multiple--light.png differ diff --git a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png index 62eccad63930c..08071b78a9b0a 100644 Binary files a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png and b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png differ diff --git a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png index afefe8eb9e546..476e3c816a76b 100644 Binary files a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png and b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png differ diff --git a/frontend/src/lib/components/PropertyFilters/PropertyFilters.tsx b/frontend/src/lib/components/PropertyFilters/PropertyFilters.tsx index 34e2cb8f646b6..a3dd36bcedff4 100644 --- a/frontend/src/lib/components/PropertyFilters/PropertyFilters.tsx +++ b/frontend/src/lib/components/PropertyFilters/PropertyFilters.tsx @@ -3,7 +3,7 @@ import './PropertyFilters.scss' import { BindLogic, useActions, useValues } from 'kea' import { TaxonomicPropertyFilter } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter' import { TaxonomicFilterGroupType, TaxonomicFilterProps } from 'lib/components/TaxonomicFilter/types' -import React, { useEffect } from 'react' +import React, { useEffect, useState } from 'react' import { LogicalRowDivider } from 'scenes/cohorts/CohortFilters/CohortCriteriaRowBuilder' import { AnyDataNode } from '~/queries/schema' @@ -32,6 +32,7 @@ interface PropertyFiltersProps { hasRowOperator?: boolean sendAllKeyUpdates?: boolean allowNew?: boolean + openOnInsert?: boolean errorMessages?: JSX.Element[] | null propertyAllowList?: { [key in TaxonomicFilterGroupType]?: string[] } allowRelativeDateOptions?: boolean @@ -56,6 +57,7 @@ export function PropertyFilters({ hasRowOperator = true, sendAllKeyUpdates = false, allowNew = true, + openOnInsert = false, errorMessages = null, propertyAllowList, allowRelativeDateOptions, @@ -63,12 +65,18 @@ export function PropertyFilters({ const logicProps = { propertyFilters, onChange, pageKey, sendAllKeyUpdates } const { filters, filtersWithNew } = useValues(propertyFilterLogic(logicProps)) const { remove, setFilters } = useActions(propertyFilterLogic(logicProps)) + const [allowOpenOnInsert, setAllowOpenOnInsert] = useState(false) // Update the logic's internal filters when the props change useEffect(() => { setFilters(propertyFilters ?? []) }, [propertyFilters]) + // do not open on initial render, only open if newly inserted + useEffect(() => { + setAllowOpenOnInsert(true) + }, []) + return (
{showNestedArrow && !disablePopover && ( @@ -120,6 +128,7 @@ export function PropertyFilters({ /> )} errorMessage={errorMessages && errorMessages[index]} + openOnInsert={allowOpenOnInsert && openOnInsert} /> ) diff --git a/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx b/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx index 0c051c2a2d0c0..31236eaa73227 100644 --- a/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx +++ b/frontend/src/lib/components/PropertyFilters/components/FilterRow.tsx @@ -5,7 +5,7 @@ import clsx from 'clsx' import { isValidPropertyFilter } from 'lib/components/PropertyFilters/utils' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { Popover } from 'lib/lemon-ui/Popover/Popover' -import React, { useState } from 'react' +import React, { useEffect, useState } from 'react' import { AnyPropertyFilter, PathCleaningFilter } from '~/types' @@ -22,6 +22,7 @@ interface FilterRowProps { disablePopover?: boolean filterComponent: (onComplete: () => void) => JSX.Element label: string + openOnInsert?: boolean onRemove: (index: number) => void orFiltering?: boolean errorMessage?: JSX.Element | null @@ -35,6 +36,7 @@ export const FilterRow = React.memo(function FilterRow({ showConditionBadge, totalCount, disablePopover = false, // use bare PropertyFilter without popover + openOnInsert = false, filterComponent, label, onRemove, @@ -43,6 +45,10 @@ export const FilterRow = React.memo(function FilterRow({ }: FilterRowProps) { const [open, setOpen] = useState(false) + useEffect(() => { + setOpen(openOnInsert) + }, []) + const { key } = item const handleVisibleChange = (visible: boolean): void => { diff --git a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss index f5b6972d48465..c02606bf6c187 100644 --- a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss +++ b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss @@ -3,7 +3,6 @@ flex-direction: column; align-items: stretch; overflow: hidden; - background: var(--bg-light); border: 1px solid var(--border); border-radius: var(--radius); } @@ -22,6 +21,7 @@ min-height: 2.875rem !important; padding: 0.5rem 0.75rem !important; // Override reduced side padding font-weight: 500 !important; // Override status="stealth"'s font-weight + background: var(--bg-light); border-radius: 0 !important; &.LemonButton:active { @@ -33,7 +33,6 @@ box-sizing: content-box; height: 0; overflow: hidden; - background: var(--bg-light); border-top-width: 1px; transition: height 200ms ease; } diff --git a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx index b838ee923481a..55b9743c907bd 100644 --- a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx +++ b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx @@ -7,18 +7,20 @@ import { Transition } from 'react-transition-group' import { ENTERED, ENTERING } from 'react-transition-group/Transition' import useResizeObserver from 'use-resize-observer' -import { LemonButton } from '../LemonButton' +import { LemonButton, LemonButtonProps } from '../LemonButton' export interface LemonCollapsePanel { key: K header: ReactNode content: ReactNode + className?: string } interface LemonCollapsePropsBase { /** Panels in order of display. Falsy values mean that the panel isn't rendered. */ panels: (LemonCollapsePanel | null | false)[] className?: string + size?: LemonButtonProps['size'] } interface LemonCollapsePropsSingle extends LemonCollapsePropsBase { @@ -40,6 +42,7 @@ type LemonCollapseProps = LemonCollapsePropsSingle | Lem export function LemonCollapse({ panels, className, + size, ...props }: LemonCollapseProps): JSX.Element { let isPanelExpanded: (key: K) => boolean @@ -74,6 +77,7 @@ export function LemonCollapse({ onPanelChange(key, isExanded)} /> @@ -86,10 +90,19 @@ interface LemonCollapsePanelProps { header: ReactNode content: ReactNode isExpanded: boolean + size: LemonButtonProps['size'] onChange: (isExpanded: boolean) => void + className?: string } -function LemonCollapsePanel({ header, content, isExpanded, onChange }: LemonCollapsePanelProps): JSX.Element { +function LemonCollapsePanel({ + header, + content, + isExpanded, + size, + className, + onChange, +}: LemonCollapsePanelProps): JSX.Element { const { height: contentHeight, ref: contentRef } = useResizeObserver({ box: 'border-box' }) return ( @@ -98,6 +111,7 @@ function LemonCollapsePanel({ header, content, isExpanded, onChange }: LemonColl onClick={() => onChange(!isExpanded)} icon={isExpanded ? : } className="LemonCollapsePanel__header" + size={size} > {header} @@ -115,7 +129,7 @@ function LemonCollapsePanel({ header, content, isExpanded, onChange }: LemonColl } aria-busy={status.endsWith('ing')} > -
+
{content}
diff --git a/frontend/src/scenes/early-access-features/InstructionsModal.tsx b/frontend/src/scenes/early-access-features/InstructionsModal.tsx index 59968e87c5d7d..c0b05f009d1f7 100644 --- a/frontend/src/scenes/early-access-features/InstructionsModal.tsx +++ b/frontend/src/scenes/early-access-features/InstructionsModal.tsx @@ -17,7 +17,7 @@ export function InstructionsModal({ onClose, visible, featureFlag }: Instruction const getCloudPanels = (): JSX.Element => (
diff --git a/frontend/src/scenes/experiments/SecondaryMetricsResult.tsx b/frontend/src/scenes/experiments/SecondaryMetricsResult.tsx index 939045cb798d3..1e120a24e9e6c 100644 --- a/frontend/src/scenes/experiments/SecondaryMetricsResult.tsx +++ b/frontend/src/scenes/experiments/SecondaryMetricsResult.tsx @@ -23,7 +23,7 @@ export function SecondaryMetricsResult(): JSX.Element { ) : ( { diff --git a/frontend/src/scenes/groups/Group.tsx b/frontend/src/scenes/groups/Group.tsx index e418282c1a748..41cecc15fb50c 100644 --- a/frontend/src/scenes/groups/Group.tsx +++ b/frontend/src/scenes/groups/Group.tsx @@ -141,7 +141,7 @@ export function Group(): JSX.Element { setOpenSections(keys)} multiple panels={[ diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx index 7c854998efc6f..f6e952900e54c 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx @@ -1,13 +1,13 @@ import { createPostHogWidgetNode } from 'scenes/notebooks/Nodes/NodeWrapper' import { FilterType, NotebookNodeType, RecordingFilters, ReplayTabs } from '~/types' import { + DEFAULT_SIMPLE_RECORDING_FILTERS, SessionRecordingPlaylistLogicProps, - addedAdvancedFilters, getDefaultFilters, sessionRecordingsPlaylistLogic, } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { BuiltLogic, useActions, useValues } from 'kea' -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo } from 'react' import { urls } from 'scenes/urls' import { notebookNodeLogic } from './notebookNodeLogic' import { JSONContent, NotebookNodeProps, NotebookNodeAttributeProperties } from '../Notebook/utils' @@ -22,13 +22,14 @@ const Component = ({ attributes, updateAttributes, }: NotebookNodeProps): JSX.Element => { - const { filters, pinned, nodeId } = attributes + const { filters, simpleFilters, pinned, nodeId } = attributes const playerKey = `notebook-${nodeId}` const recordingPlaylistLogicProps: SessionRecordingPlaylistLogicProps = useMemo( () => ({ logicKey: playerKey, - filters, + advancedFilters: filters, + simpleFilters, updateSearchParams: false, autoPlay: false, onFiltersChange: (newFilters: RecordingFilters) => { @@ -117,25 +118,20 @@ export const Settings = ({ attributes, updateAttributes, }: NotebookNodeAttributeProperties): JSX.Element => { - const { filters } = attributes - const [showAdvancedFilters, setShowAdvancedFilters] = useState(false) + const { filters, simpleFilters } = attributes const defaultFilters = getDefaultFilters() - const hasAdvancedFilters = useMemo(() => { - const defaultFilters = getDefaultFilters() - return addedAdvancedFilters(filters, defaultFilters) - }, [filters]) - return ( updateAttributes({ filters })} + advancedFilters={{ ...defaultFilters, ...filters }} + simpleFilters={simpleFilters ?? DEFAULT_SIMPLE_RECORDING_FILTERS} + setAdvancedFilters={(filters) => updateAttributes({ filters })} + setSimpleFilters={(simpleFilters) => updateAttributes({ simpleFilters })} showPropertyFilters - onReset={() => updateAttributes({ filters: undefined })} - hasAdvancedFilters={hasAdvancedFilters} - showAdvancedFilters={showAdvancedFilters} - setShowAdvancedFilters={setShowAdvancedFilters} + onReset={() => + updateAttributes({ filters: defaultFilters, simpleFilters: DEFAULT_SIMPLE_RECORDING_FILTERS }) + } /> ) @@ -143,6 +139,7 @@ export const Settings = ({ type NotebookNodePlaylistAttributes = { filters: RecordingFilters + simpleFilters?: RecordingFilters pinned?: string[] } @@ -161,6 +158,9 @@ export const NotebookNodePlaylist = createPostHogWidgetNode void - localFilters: FilterType - setLocalFilters: (localFilters: FilterType) => void showPropertyFilters?: boolean }): JSX.Element => { const { groupsTaxonomicTypes } = useValues(groupsModel) return ( -
+
Events and actions { - setLocalFilters(payload) + setFilters({ + events: payload.events || [], + actions: payload.actions || [], + }) }} typeKey="session-recordings" mathAvailability={MathAvailability.None} @@ -81,6 +80,7 @@ export const AdvancedSessionRecordingsFilters = ({ )} Time and duration +
void + advancedFilters: RecordingFilters + simpleFilters: RecordingFilters + setAdvancedFilters: (filters: RecordingFilters) => void + setSimpleFilters: (filters: RecordingFilters) => void showPropertyFilters?: boolean + hideSimpleFilters?: boolean onReset?: () => void - hasAdvancedFilters: boolean - showAdvancedFilters: boolean - setShowAdvancedFilters: (showAdvancedFilters: boolean) => void -} - -const filtersToLocalFilters = (filters: RecordingFilters): LocalRecordingFilters => { - if (filters.actions?.length || filters.events?.length) { - return { - actions: filters.actions, - events: filters.events, - } - } - - return { - actions: [], - events: [], - new_entity: [ - { - id: 'empty', - type: EntityTypes.EVENTS, - order: 0, - name: 'empty', - }, - ], - } } export function SessionRecordingsFilters({ - filters, - setFilters, + advancedFilters, + simpleFilters, + setAdvancedFilters, + setSimpleFilters, showPropertyFilters, + hideSimpleFilters, onReset, - hasAdvancedFilters, - showAdvancedFilters, - setShowAdvancedFilters, }: SessionRecordingsFiltersProps): JSX.Element { - const [localFilters, setLocalFilters] = useState(filtersToLocalFilters(filters)) - - // We have a copy of the filters as local state as it stores more properties than we want for playlists - useEffect(() => { - if (!equal(filters.actions, localFilters.actions) || !equal(filters.events, localFilters.events)) { - setFilters({ - actions: localFilters.actions, - events: localFilters.events, - }) + const initiallyOpen = useMemo(() => { + // advanced always open if not showing simple filters, saves computation + if (hideSimpleFilters) { + return true } - }, [localFilters]) + const defaultFilters = getDefaultFilters() + return !equal(advancedFilters, defaultFilters) + }, []) - useEffect(() => { - // We have a copy of the filters as local state as it stores more properties than we want for playlists - // if (!equal(filters.actions, localFilters.actions) || !equal(filters.events, localFilters.events)) { - if (!equal(filters.actions, localFilters.actions) || !equal(filters.events, localFilters.events)) { - setLocalFilters(filtersToLocalFilters(filters)) - } - }, [filters]) + const AdvancedFilters = ( + + ) return ( -
-
- {onReset && ( - - - Reset - - - )} +
+
+
+ Find sessions: - Find sessions by: + {onReset && ( + + + Reset + + + )} +
- + )} +
+ + {hideSimpleFilters ? ( + AdvancedFilters + ) : ( + setShowAdvancedFilters(newValue === 'advanced')} - data-attr={`session-recordings-show-${showAdvancedFilters ? 'simple' : 'advanced'}-filters`} - fullWidth - /> -
- - {showAdvancedFilters ? ( - - ) : ( - )}
diff --git a/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx b/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx index 0f692d62e507b..92fda17c8058d 100644 --- a/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx +++ b/frontend/src/scenes/session-recordings/filters/SimpleSessionRecordingsFilters.tsx @@ -1,116 +1,110 @@ -import { urls } from '@posthog/apps-common' import { IconPlus } from '@posthog/icons' -import { LemonButton, Link } from '@posthog/lemon-ui' -import { BindLogic, useActions, useValues } from 'kea' -import { TaxonomicPropertyFilter } from 'lib/components/PropertyFilters/components/TaxonomicPropertyFilter' -import { propertyFilterLogic } from 'lib/components/PropertyFilters/propertyFilterLogic' +import { LemonButton, LemonMenu } from '@posthog/lemon-ui' +import { useValues } from 'kea' import { PropertyFilters } from 'lib/components/PropertyFilters/PropertyFilters' -import { PropertyFilterLogicProps } from 'lib/components/PropertyFilters/types' import { TaxonomicFilterGroupType } from 'lib/components/TaxonomicFilter/types' -import { Popover } from 'lib/lemon-ui/Popover/Popover' -import { useMemo, useState } from 'react' +import { useMemo } from 'react' import { teamLogic } from 'scenes/teamLogic' +import { urls } from 'scenes/urls' -import { - AnyPropertyFilter, - EntityTypes, - FilterType, - PropertyFilterType, - PropertyOperator, - RecordingFilters, -} from '~/types' +import { EntityTypes, EventPropertyFilter, PropertyFilterType, PropertyOperator, RecordingFilters } from '~/types' export const SimpleSessionRecordingsFilters = ({ filters, setFilters, - localFilters, - setLocalFilters, }: { filters: RecordingFilters setFilters: (filters: RecordingFilters) => void - localFilters: FilterType - setLocalFilters: (localFilters: FilterType) => void }): JSX.Element => { const { currentTeam } = useValues(teamLogic) const displayNameProperties = useMemo(() => currentTeam?.person_display_name_properties ?? [], [currentTeam]) - const personPropertyOptions = useMemo(() => { - const properties = [{ label: 'Country', key: '$geoip_country_name' }] - return properties.concat( - displayNameProperties.slice(0, 2).map((property) => { - return { label: property, key: property } - }) - ) - }, [displayNameProperties]) - - const pageviewEvent = localFilters.events?.find((event) => event.id === '$pageview') + const pageviewEvent = filters.events?.find((event) => event.id === '$pageview') const personProperties = filters.properties || [] const eventProperties = pageviewEvent?.properties || [] - return ( -
-
- {personPropertyOptions.map(({ label, key }) => ( - property.key === key)} - onChange={(newProperties) => { - const properties = filters.properties || [] - setFilters({ ...filters, properties: [...properties, ...newProperties] }) - }} - /> - ))} - { - const events = filters.events || [] - setLocalFilters({ - ...filters, - events: [ - ...events, - { - id: '$pageview', - name: '$pageview', - type: EntityTypes.EVENTS, - properties: properties, - }, - ], - }) - }} - /> - {displayNameProperties.length === 0 && ( - - }> - Add person properties - - - )} -
+ const onClickPersonProperty = (key: string): void => { + setFilters({ + ...filters, + properties: [ + ...personProperties, + { type: PropertyFilterType.Person, key: key, value: null, operator: PropertyOperator.Exact }, + ], + }) + } + + const onClickCurrentUrl = (): void => { + const events = filters.events || [] + setFilters({ + ...filters, + events: [ + ...events, + { + id: '$pageview', + name: '$pageview', + type: EntityTypes.EVENTS, + properties: [ + { + type: PropertyFilterType.Event, + key: '$current_url', + value: null, + operator: PropertyOperator.Exact, + }, + ], + }, + ], + }) + } + + const items = useMemo(() => { + const personKeys = personProperties.map((p) => p.key) + const eventKeys = eventProperties.map((p: EventPropertyFilter) => p.key) + + const properties = [ + !personKeys.includes('$geoip_country_name') && { + label: 'Country', + key: '$geoip_country_name', + onClick: () => onClickPersonProperty('$geoip_country_name'), + }, + !eventKeys.includes('$current_url') && { + label: 'URL', + key: '$current_url', + onClick: onClickCurrentUrl, + }, + ] - {personProperties && ( + displayNameProperties.forEach((property) => { + properties.push( + !personKeys.includes(property) && { + label: property, + key: property, + onClick: () => onClickPersonProperty(property), + } + ) + }) + + return properties.filter(Boolean) + }, [displayNameProperties, personProperties, eventProperties]) + + return ( +
+
setFilters({ properties })} allowNew={false} + openOnInsert /> - )} - {pageviewEvent && ( { - setLocalFilters({ + setFilters({ ...filters, events: properties.length > 0 @@ -126,78 +120,31 @@ export const SimpleSessionRecordingsFilters = ({ }) }} allowNew={false} + openOnInsert /> - )} -
- ) -} - -const SimpleSessionRecordingsFiltersInserter = ({ - propertyKey, - type, - label, - disabled, - onChange, -}: { - propertyKey: string - type: PropertyFilterType.Event | PropertyFilterType.Person - label: string - disabled: boolean - onChange: (properties: AnyPropertyFilter[]) => void -}): JSX.Element => { - const [open, setOpen] = useState(false) - - const pageKey = `session-recordings-inserter-${propertyKey}` - - const logicProps: PropertyFilterLogicProps = { - propertyFilters: [{ type: type, key: propertyKey, operator: PropertyOperator.Exact }], - onChange, - pageKey: pageKey, - sendAllKeyUpdates: false, - } - - const { setFilters } = useActions(propertyFilterLogic(logicProps)) - - const handleVisibleChange = (visible: boolean): void => { - if (!visible) { - setFilters([{ type: type, key: propertyKey, operator: PropertyOperator.Exact }]) - } - - setOpen(visible) - } - - return ( - - handleVisibleChange(false)} - overlay={ - handleVisibleChange(false)} - orFiltering={false} - taxonomicGroupTypes={[TaxonomicFilterGroupType.PersonProperties]} - propertyGroupType={null} - disablePopover={false} - selectProps={{}} - /> - } - > - handleVisibleChange(true)} - className="new-prop-filter" - type="secondary" - size="small" - disabledReason={disabled && 'Add more values using your existing filter.'} - sideIcon={null} + 0 && { + title: 'Preferred properties', + items: items, + }, + { + items: [ + { + label: `${ + displayNameProperties.length === 0 ? 'Add' : 'Edit' + } person display properties`, + to: urls.settings('project-product-analytics', 'person-display-name'), + }, + ], + }, + ]} > - {label} - - - + }> + Choose filter + + +
+
) } diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx index 4c7b31e02420a..c1ef839a5c23d 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx @@ -70,7 +70,7 @@ function UnusableEventsWarning(props: { unusableEventsInFilter: string[] }): JSX } function PinnedRecordingsList(): JSX.Element | null { - const { setSelectedRecordingId, setFilters } = useActions(sessionRecordingsPlaylistLogic) + const { setSelectedRecordingId, setAdvancedFilters } = useActions(sessionRecordingsPlaylistLogic) const { activeSessionRecordingId, filters, pinnedRecordings } = useValues(sessionRecordingsPlaylistLogic) const { featureFlags } = useValues(featureFlagLogic) @@ -93,7 +93,7 @@ function PinnedRecordingsList(): JSX.Element | null { recording={rec} onClick={() => setSelectedRecordingId(rec.id)} onPropertyClick={(property, value) => - setFilters(defaultPageviewPropertyEntityFilter(filters, property, value)) + setAdvancedFilters(defaultPageviewPropertyEntityFilter(filters, property, value)) } isActive={activeSessionRecordingId === rec.id} pinned={true} @@ -107,6 +107,8 @@ function PinnedRecordingsList(): JSX.Element | null { function RecordingsLists(): JSX.Element { const { filters, + advancedFilters, + simpleFilters, hasNext, pinnedRecordings, otherRecordings, @@ -117,8 +119,6 @@ function RecordingsLists(): JSX.Element { totalFiltersCount, sessionRecordingsAPIErrored, unusableEventsInFilter, - showAdvancedFilters, - hasAdvancedFilters, logicProps, showOtherRecordings, recordingsCount, @@ -127,12 +127,12 @@ function RecordingsLists(): JSX.Element { } = useValues(sessionRecordingsPlaylistLogic) const { setSelectedRecordingId, - setFilters, + setAdvancedFilters, + setSimpleFilters, maybeLoadSessionRecordings, setShowFilters, setShowSettings, resetFilters, - setShowAdvancedFilters, toggleShowOtherRecordings, summarizeSession, } = useActions(sessionRecordingsPlaylistLogic) @@ -142,7 +142,7 @@ function RecordingsLists(): JSX.Element { } const onPropertyClick = (property: string, value?: string): void => { - setFilters(defaultPageviewPropertyEntityFilter(filters, property, value)) + setAdvancedFilters(defaultPageviewPropertyEntityFilter(advancedFilters, property, value)) } const onSummarizeClick = (recording: SessionRecordingType): void => { @@ -239,13 +239,13 @@ function RecordingsLists(): JSX.Element { {!notebookNode && showFilters ? (
resetFilters() : undefined} - hasAdvancedFilters={hasAdvancedFilters} - showAdvancedFilters={showAdvancedFilters} - setShowAdvancedFilters={setShowAdvancedFilters} + onReset={resetFilters} />
) : showSettings ? ( @@ -322,7 +322,7 @@ function RecordingsLists(): JSX.Element { type="secondary" data-attr="expand-replay-listing-from-default-seven-days-to-twenty-one" onClick={() => { - setFilters({ + setAdvancedFilters({ date_from: '-30d', }) }} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx index 056d80cc35ac7..b9a504d182821 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx @@ -134,7 +134,8 @@ export function SessionRecordingsPlaylistScene(): JSX.Element { {playlist.short_id && pinnedRecordings !== null ? (
{ const aRecording = { id: 'abc', viewed: false, recording_duration: 10 } const listOfSessionRecordings = [aRecording] - describe('with no recordings to load', () => { - beforeEach(() => { - useMocks({ - get: { - '/api/projects/:team/session_recordings/properties': { - results: [], - }, - - '/api/projects/:team/session_recordings': { has_next: false, results: [] }, - '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': { - results: [], - }, + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/session_recordings/properties': { + results: [ + { id: 's1', properties: { blah: 'blah1' } }, + { id: 's2', properties: { blah: 'blah2' } }, + ], }, - }) - initKeaTests() - logic = sessionRecordingsPlaylistLogic({ - key: 'tests', - updateSearchParams: true, - }) - logic.mount() - }) - - describe('should show empty state', () => { - it('starts out false', async () => { - await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) - }) - - it('is true if after API call is made there are no results', async () => { - await expectLogic(logic, () => { - logic.actions.setSelectedRecordingId('abc') - }) - .toDispatchActionsInAnyOrder(['loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ shouldShowEmptyState: true }) - }) - it('is false after API call error', async () => { - await expectLogic(logic, () => { - logic.actions.loadSessionRecordingsFailure('abc') - }).toMatchValues({ shouldShowEmptyState: false }) - }) - }) - }) - - describe('with recordings to load', () => { - beforeEach(() => { - useMocks({ - get: { - '/api/projects/:team/session_recordings/properties': { - results: [ - { id: 's1', properties: { blah: 'blah1' } }, - { id: 's2', properties: { blah: 'blah2' } }, - ], - }, - - '/api/projects/:team/session_recordings': (req) => { - const { searchParams } = req.url - if ( - (searchParams.get('events')?.length || 0) > 0 && - JSON.parse(searchParams.get('events') || '[]')[0]?.['id'] === '$autocapture' - ) { - return [ - 200, - { - results: ['List of recordings filtered by events'], - }, - ] - } else if (searchParams.get('person_uuid') === 'cool_user_99') { - return [ - 200, - { - results: ["List of specific user's recordings from server"], - }, - ] - } else if (searchParams.get('offset') === `${RECORDINGS_LIMIT}`) { - return [ - 200, - { - results: [`List of recordings offset by ${RECORDINGS_LIMIT}`], - }, - ] - } else if ( - searchParams.get('date_from') === '2021-10-05' && - searchParams.get('date_to') === '2021-10-20' - ) { - return [ - 200, - { - results: ['Recordings filtered by date'], - }, - ] - } else if ( - JSON.parse(searchParams.get('session_recording_duration') ?? '{}')['value'] === 600 - ) { - return [ - 200, - { - results: ['Recordings filtered by duration'], - }, - ] - } + '/api/projects/:team/session_recordings': (req) => { + const { searchParams } = req.url + if ( + (searchParams.get('events')?.length || 0) > 0 && + JSON.parse(searchParams.get('events') || '[]')[0]?.['id'] === '$autocapture' + ) { return [ 200, { - results: listOfSessionRecordings, + results: ['List of recordings filtered by events'], }, ] - }, - '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': () => { + } else if (searchParams.get('person_uuid') === 'cool_user_99') { return [ 200, { - results: ['Pinned recordings'], + results: ["List of specific user's recordings from server"], }, ] - }, + } else if (searchParams.get('offset') === `${RECORDINGS_LIMIT}`) { + return [ + 200, + { + results: [`List of recordings offset by ${RECORDINGS_LIMIT}`], + }, + ] + } else if ( + searchParams.get('date_from') === '2021-10-05' && + searchParams.get('date_to') === '2021-10-20' + ) { + return [ + 200, + { + results: ['Recordings filtered by date'], + }, + ] + } else if (JSON.parse(searchParams.get('session_recording_duration') ?? '{}')['value'] === 600) { + return [ + 200, + { + results: ['Recordings filtered by duration'], + }, + ] + } + return [ + 200, + { + results: listOfSessionRecordings, + }, + ] + }, + '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': () => { + return [ + 200, + { + results: ['Pinned recordings'], + }, + ] }, + }, + }) + initKeaTests() + }) + + describe('global logic', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'tests', + updateSearchParams: true, }) - initKeaTests() + logic.mount() }) - describe('global logic', () => { - beforeEach(() => { - logic = sessionRecordingsPlaylistLogic({ - key: 'tests', - updateSearchParams: true, + describe('core assumptions', () => { + it('loads recent recordings after mounting', async () => { + await expectLogic(logic).toDispatchActionsInAnyOrder(['loadSessionRecordingsSuccess']).toMatchValues({ + sessionRecordings: listOfSessionRecordings, }) - logic.mount() }) + }) - describe('core assumptions', () => { - it('loads recent recordings after mounting', async () => { - await expectLogic(logic) - .toDispatchActionsInAnyOrder(['loadSessionRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: listOfSessionRecordings, - }) - }) + describe('activeSessionRecording', () => { + it('starts as null', () => { + expectLogic(logic).toMatchValues({ activeSessionRecording: undefined }) + }) + it('is set by setSessionRecordingId', () => { + expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc')) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'abc', + activeSessionRecording: listOfSessionRecordings[0], + }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') }) - describe('should show empty state', () => { - it('starts out false', async () => { - await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) - }) + it('is partial if sessionRecordingId not in list', () => { + expectLogic(logic, () => logic.actions.setSelectedRecordingId('not-in-list')) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'not-in-list', + activeSessionRecording: { id: 'not-in-list' }, + }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'not-in-list') }) - describe('activeSessionRecording', () => { - it('starts as null', () => { - expectLogic(logic).toMatchValues({ activeSessionRecording: undefined }) - }) - it('is set by setSessionRecordingId', () => { - expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc')) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'abc', - activeSessionRecording: listOfSessionRecordings[0], - }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') - }) + it('is read from the URL on the session recording page', async () => { + router.actions.push('/replay', { sessionRecordingId: 'abc' }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') - it('is partial if sessionRecordingId not in list', () => { - expectLogic(logic, () => logic.actions.setSelectedRecordingId('not-in-list')) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'not-in-list', - activeSessionRecording: { id: 'not-in-list' }, - }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'not-in-list') - }) + await expectLogic(logic) + .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'abc', + activeSessionRecording: listOfSessionRecordings[0], + }) + }) - it('is read from the URL on the session recording page', async () => { - router.actions.push('/replay', {}, { sessionRecordingId: 'abc' }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + it('mounts and loads the recording when a recording is opened', () => { + expectLogic(logic, async () => logic.asyncActions.setSelectedRecordingId('abcd')) + .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' })) + .toDispatchActions(['loadEntireRecording']) + }) - await expectLogic(logic) - .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'abc', - activeSessionRecording: listOfSessionRecordings[0], - }) + it('returns the first session recording if none selected', () => { + expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ + selectedRecordingId: undefined, + activeSessionRecording: listOfSessionRecordings[0], }) + expect(router.values.searchParams).not.toHaveProperty('sessionRecordingId', 'not-in-list') + }) + }) - it('mounts and loads the recording when a recording is opened', () => { - expectLogic(logic, async () => logic.asyncActions.setSelectedRecordingId('abcd')) - .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' })) - .toDispatchActions(['loadEntireRecording']) + describe('entityFilters', () => { + it('starts with default values', () => { + expectLogic(logic).toMatchValues({ + filters: DEFAULT_RECORDING_FILTERS, + simpleFilters: DEFAULT_SIMPLE_RECORDING_FILTERS, }) + }) - it('returns the first session recording if none selected', () => { - expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ - selectedRecordingId: undefined, - activeSessionRecording: listOfSessionRecordings[0], + it('is set by setAdvancedFilters and loads filtered results and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], }) - expect(router.values.searchParams).not.toHaveProperty('sessionRecordingId', 'not-in-list') }) + .toDispatchActions(['setAdvancedFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + sessionRecordings: ['List of recordings filtered by events'], + }) + expect(router.values.searchParams.advancedFilters).toHaveProperty('events', [ + { id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }, + ]) }) - describe('entityFilters', () => { - it('starts with default values', () => { - expectLogic(logic).toMatchValues({ filters: DEFAULT_RECORDING_FILTERS }) + it('reads filters from the logic props', async () => { + logic = sessionRecordingsPlaylistLogic({ + key: 'tests-with-props', + advancedFilters: { + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + }, + simpleFilters: { + properties: [ + { + key: '$geoip_country_name', + value: ['Australia'], + operator: PropertyOperator.Exact, + type: PropertyFilterType.Person, + }, + ], + }, }) + logic.mount() - it('is set by setFilters and loads filtered results and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - }) - }) - .toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: ['List of recordings filtered by events'], - }) - expect(router.values.searchParams.filters).toHaveProperty('events', [ - { id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }, - ]) + await expectLogic(logic).toMatchValues({ + advancedFilters: { + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + }, + simpleFilters: { + properties: [ + { key: '$geoip_country_name', value: ['Australia'], operator: 'exact', type: 'person' }, + ], + }, }) }) + }) - describe('date range', () => { - it('is set by setFilters and fetches results from server and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ + describe('date range', () => { + it('is set by setAdvancedFilters and fetches results from server and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + date_from: '2021-10-05', + date_to: '2021-10-20', + }) + }) + .toMatchValues({ + filters: expect.objectContaining({ date_from: '2021-10-05', date_to: '2021-10-20', - }) + }), }) - .toMatchValues({ - filters: expect.objectContaining({ - date_from: '2021-10-05', - date_to: '2021-10-20', - }), - }) - .toDispatchActions(['setFilters', 'loadSessionRecordingsSuccess']) - .toMatchValues({ sessionRecordings: ['Recordings filtered by date'] }) - - expect(router.values.searchParams.filters).toHaveProperty('date_from', '2021-10-05') - expect(router.values.searchParams.filters).toHaveProperty('date_to', '2021-10-20') - }) + .toDispatchActions(['setAdvancedFilters', 'loadSessionRecordingsSuccess']) + .toMatchValues({ sessionRecordings: ['Recordings filtered by date'] }) + + expect(router.values.searchParams.advancedFilters).toHaveProperty('date_from', '2021-10-05') + expect(router.values.searchParams.advancedFilters).toHaveProperty('date_to', '2021-10-20') }) - describe('duration filter', () => { - it('is set by setFilters and fetches results from server and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ + }) + describe('duration filter', () => { + it('is set by setAdvancedFilters and fetches results from server and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + session_recording_duration: { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }, + }) + }) + .toMatchValues({ + filters: expect.objectContaining({ session_recording_duration: { type: PropertyFilterType.Recording, key: 'duration', value: 600, operator: PropertyOperator.LessThan, }, - }) - }) - .toMatchValues({ - filters: expect.objectContaining({ - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }, - }), - }) - .toDispatchActions(['setFilters', 'loadSessionRecordingsSuccess']) - .toMatchValues({ sessionRecordings: ['Recordings filtered by duration'] }) - - expect(router.values.searchParams.filters).toHaveProperty('session_recording_duration', { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, + }), }) + .toDispatchActions(['setAdvancedFilters', 'loadSessionRecordingsSuccess']) + .toMatchValues({ sessionRecordings: ['Recordings filtered by duration'] }) + + expect(router.values.searchParams.advancedFilters).toHaveProperty('session_recording_duration', { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, }) }) + }) - describe('set recording from hash param', () => { - it('loads the correct recording from the hash params', async () => { - router.actions.push('/replay/recent', {}, { sessionRecordingId: 'abc' }) - - logic = sessionRecordingsPlaylistLogic({ - key: 'hash-recording-tests', - updateSearchParams: true, - }) - logic.mount() + describe('set recording from hash param', () => { + it('loads the correct recording from the hash params', async () => { + router.actions.push('/replay/recent', { sessionRecordingId: 'abc' }) - await expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ - selectedRecordingId: 'abc', - }) + logic = sessionRecordingsPlaylistLogic({ + key: 'hash-recording-tests', + updateSearchParams: true, + }) + logic.mount() - logic.actions.setSelectedRecordingId('1234') + await expectLogic(logic).toDispatchActions(['loadSessionRecordingsSuccess']).toMatchValues({ + selectedRecordingId: 'abc', }) + + logic.actions.setSelectedRecordingId('1234') }) + }) - describe('sessionRecording.viewed', () => { - it('changes when setSelectedRecordingId is called', async () => { - await expectLogic(logic) - .toFinishAllListeners() - .toMatchValues({ - sessionRecordingsResponse: { - results: [{ ...aRecording }], - has_next: undefined, + describe('sessionRecording.viewed', () => { + it('changes when setSelectedRecordingId is called', async () => { + await expectLogic(logic) + .toFinishAllListeners() + .toMatchValues({ + sessionRecordingsResponse: { + results: [{ ...aRecording }], + has_next: undefined, + }, + sessionRecordings: [ + { + ...aRecording, }, - sessionRecordings: [ - { - ...aRecording, - }, - ], - }) - - await expectLogic(logic, () => { - logic.actions.setSelectedRecordingId('abc') + ], }) - .toFinishAllListeners() - .toMatchValues({ - sessionRecordingsResponse: { - results: [ - { - ...aRecording, - // at this point the view hasn't updated this object - viewed: false, - }, - ], - }, - sessionRecordings: [ + + await expectLogic(logic, () => { + logic.actions.setSelectedRecordingId('abc') + }) + .toFinishAllListeners() + .toMatchValues({ + sessionRecordingsResponse: { + results: [ { ...aRecording, - viewed: true, + // at this point the view hasn't updated this object + viewed: false, }, ], - }) - }) + }, + sessionRecordings: [ + { + ...aRecording, + viewed: true, + }, + ], + }) + }) - it('is set by setFilters and loads filtered results', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - }) + it('is set by setAdvancedFilters and loads filtered results', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], }) - .toDispatchActions(['setFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: ['List of recordings filtered by events'], - }) }) + .toDispatchActions(['setAdvancedFilters', 'loadSessionRecordings', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + sessionRecordings: ['List of recordings filtered by events'], + }) + }) + }) + + it('reads filters from the URL', async () => { + router.actions.push('/replay', { + filters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + date_from: '2021-10-01', + date_to: '2021-10-10', + offset: 50, + session_recording_duration: { + type: PropertyFilterType.Recording, + key: 'duration', + value: 600, + operator: PropertyOperator.LessThan, + }, + }, }) - it('reads filters from the URL', async () => { - router.actions.push('/replay', { + await expectLogic(logic) + .toDispatchActions(['setAdvancedFilters']) + .toMatchValues({ filters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], date_from: '2021-10-01', date_to: '2021-10-10', offset: 50, + console_logs: [], + console_search_query: '', + properties: [], session_recording_duration: { type: PropertyFilterType.Recording, key: 'duration', @@ -376,142 +378,177 @@ describe('sessionRecordingsPlaylistLogic', () => { }, }, }) + }) - await expectLogic(logic) - .toDispatchActions(['setFilters']) - .toMatchValues({ - filters: { - events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - date_from: '2021-10-01', - date_to: '2021-10-10', - offset: 50, - console_logs: [], - properties: [], - session_recording_duration: { - type: PropertyFilterType.Recording, - key: 'duration', - value: 600, - operator: PropertyOperator.LessThan, - }, - }, - }) + it('reads filters from the URL and defaults the duration filter', async () => { + router.actions.push('/replay', { + advancedFilters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + }, }) - it('reads filters from the URL and defaults the duration filter', async () => { - router.actions.push('/replay', { + await expectLogic(logic) + .toDispatchActions(['setAdvancedFilters']) + .toMatchValues({ + advancedFilters: expect.objectContaining({ + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + }), filters: { actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], + session_recording_duration: defaultRecordingDurationFilter, + console_logs: [], + console_search_query: '', + date_from: '-7d', + date_to: null, + events: [], + properties: [], }, }) + }) - await expectLogic(logic) - .toDispatchActions(['setFilters']) - .toMatchValues({ - customFilters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - }, - filters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - session_recording_duration: defaultRecordingDurationFilter, - console_logs: [], - date_from: '-7d', - date_to: null, - events: [], - properties: [], - }, - }) + it('reads advanced filters from the URL', async () => { + router.actions.push('/replay', { + advancedFilters: { + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + }, }) - }) - describe('person specific logic', () => { - beforeEach(() => { - logic = sessionRecordingsPlaylistLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, + await expectLogic(logic) + .toDispatchActions(['setAdvancedFilters']) + .toMatchValues({ + advancedFilters: expect.objectContaining({ + events: [{ id: '$autocapture', type: 'events', order: 0, name: '$autocapture' }], + }), }) - logic.mount() - }) + }) - it('loads session recordings for a specific user', async () => { - await expectLogic(logic) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ sessionRecordings: ["List of specific user's recordings from server"] }) + it('reads simple filters from the URL', async () => { + router.actions.push('/replay', { + simpleFilters: { + properties: [ + { + key: '$geoip_country_name', + value: ['Australia'], + operator: PropertyOperator.Exact, + type: PropertyFilterType.Person, + }, + ], + }, }) - it('reads sessionRecordingId from the URL on the person page', async () => { - router.actions.push('/person/123', {}, { sessionRecordingId: 'abc' }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + await expectLogic(logic) + .toDispatchActions(['setSimpleFilters']) + .toMatchValues({ + simpleFilters: { + events: [], + properties: [ + { + key: '$geoip_country_name', + value: ['Australia'], + operator: PropertyOperator.Exact, + type: PropertyFilterType.Person, + }, + ], + }, + }) + }) + }) - await expectLogic(logic).toDispatchActions([logic.actionCreators.setSelectedRecordingId('abc')]) + describe('person specific logic', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, }) + logic.mount() }) - describe('total filters count', () => { - beforeEach(() => { - logic = sessionRecordingsPlaylistLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - logic.mount() - }) - it('starts with a count of zero', async () => { - await expectLogic(logic).toMatchValues({ totalFiltersCount: 0 }) - }) + it('loads session recordings for a specific user', async () => { + await expectLogic(logic) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ sessionRecordings: ["List of specific user's recordings from server"] }) + }) - it('counts console log filters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - console_logs: ['warn', 'error'], - } satisfies Partial) - }).toMatchValues({ totalFiltersCount: 2 }) - }) + it('reads sessionRecordingId from the URL on the person page', async () => { + router.actions.push('/person/123', { sessionRecordingId: 'abc' }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + + await expectLogic(logic).toDispatchActions([logic.actionCreators.setSelectedRecordingId('abc')]) }) + }) - describe('resetting filters', () => { - beforeEach(() => { - logic = sessionRecordingsPlaylistLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - logic.mount() + describe('total filters count', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, }) + logic.mount() + }) + it('starts with a count of zero', async () => { + await expectLogic(logic).toMatchValues({ totalFiltersCount: 0 }) + }) - it('resets console log filters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - console_logs: ['warn', 'error'], - } satisfies Partial) - logic.actions.resetFilters() - }).toMatchValues({ totalFiltersCount: 0 }) + it('counts console log filters', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + console_logs: ['warn', 'error'], + } satisfies Partial) + }).toMatchValues({ totalFiltersCount: 2 }) + }) + + it('counts console log search query', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + console_search_query: 'this is a test', + } satisfies Partial) + }).toMatchValues({ totalFiltersCount: 1 }) + }) + }) + + describe('resetting filters', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, }) + logic.mount() }) - describe('pinned playlists', () => { - it('should not show others if there are pinned recordings', () => { - logic = sessionRecordingsPlaylistLogic({ - key: 'tests', - updateSearchParams: true, - pinnedRecordings: ['1234'], - }) - logic.mount() + it('resets console log filters', async () => { + await expectLogic(logic, () => { + logic.actions.setAdvancedFilters({ + console_logs: ['warn', 'error'], + } satisfies Partial) + logic.actions.resetFilters() + }).toMatchValues({ totalFiltersCount: 0 }) + }) + }) - expectLogic(logic).toMatchValues({ showOtherRecordings: false }) + describe('pinned playlists', () => { + it('should not show others if there are pinned recordings', () => { + logic = sessionRecordingsPlaylistLogic({ + key: 'tests', + updateSearchParams: true, + pinnedRecordings: ['1234'], }) + logic.mount() - it('should show others if there are no pinned recordings', () => { - logic = sessionRecordingsPlaylistLogic({ - key: 'tests', - updateSearchParams: true, - pinnedRecordings: [], - }) - logic.mount() + expectLogic(logic).toMatchValues({ showOtherRecordings: false }) + }) - expectLogic(logic).toMatchValues({ showOtherRecordings: true }) + it('should show others if there are no pinned recordings', () => { + logic = sessionRecordingsPlaylistLogic({ + key: 'tests', + updateSearchParams: true, + pinnedRecordings: [], }) + logic.mount() + + expectLogic(logic).toMatchValues({ showOtherRecordings: true }) }) }) }) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 3090a9707ef0c..056bd45542faf 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -28,6 +28,8 @@ export type PersonUUID = string interface Params { filters?: RecordingFilters + simpleFilters?: RecordingFilters + advancedFilters?: RecordingFilters sessionRecordingId?: SessionRecordingId } @@ -51,6 +53,7 @@ interface BackendEventsMatching { } export type MatchingEventsMatchType = NoEventsToMatch | EventNamesMatching | EventUUIDsMatching | BackendEventsMatching +export type SimpleFiltersType = Pick export const RECORDINGS_LIMIT = 20 export const PINNED_RECORDINGS_LIMIT = 100 // NOTE: This is high but avoids the need for pagination for now... @@ -62,6 +65,11 @@ export const defaultRecordingDurationFilter: RecordingDurationFilter = { operator: PropertyOperator.GreaterThan, } +export const DEFAULT_SIMPLE_RECORDING_FILTERS: SimpleFiltersType = { + events: [], + properties: [], +} + export const DEFAULT_RECORDING_FILTERS: RecordingFilters = { session_recording_duration: defaultRecordingDurationFilter, properties: [], @@ -70,6 +78,7 @@ export const DEFAULT_RECORDING_FILTERS: RecordingFilters = { date_from: '-7d', date_to: null, console_logs: [], + console_search_query: '', } const DEFAULT_PERSON_RECORDING_FILTERS: RecordingFilters = { @@ -81,66 +90,6 @@ export const getDefaultFilters = (personUUID?: PersonUUID): RecordingFilters => return personUUID ? DEFAULT_PERSON_RECORDING_FILTERS : DEFAULT_RECORDING_FILTERS } -function isPageViewFilter(filter: Record): boolean { - return filter.name === '$pageview' -} -function isCurrentURLPageViewFilter(eventsFilter: Record): boolean { - const hasSingleProperty = Array.isArray(eventsFilter.properties) && eventsFilter.properties?.length === 1 - const isCurrentURLProperty = hasSingleProperty && eventsFilter.properties[0].key === '$current_url' - return isPageViewFilter(eventsFilter) && isCurrentURLProperty -} - -// checks are stored against filter keys so that the type system enforces adding a check when we add new filters -const advancedFilterChecks: Record< - keyof RecordingFilters, - (filters: RecordingFilters, defaultFilters: RecordingFilters) => boolean -> = { - actions: (filters) => (filters.actions ? filters.actions.length > 0 : false), - events: function (filters: RecordingFilters): boolean { - const eventsFilters = filters.events || [] - // simple filters allow a single $pageview event filter with $current_url as the selected property - // anything else is advanced - return ( - eventsFilters.length > 1 || - (!!eventsFilters[0] && - (!isPageViewFilter(eventsFilters[0]) || !isCurrentURLPageViewFilter(eventsFilters[0]))) - ) - }, - properties: function (): boolean { - // TODO is this right? should we ever care about properties for choosing between advanced and simple? - return false - }, - date_from: (filters, defaultFilters) => filters.date_from != defaultFilters.date_from, - date_to: (filters, defaultFilters) => filters.date_to != defaultFilters.date_to, - session_recording_duration: (filters, defaultFilters) => - !equal(filters.session_recording_duration, defaultFilters.session_recording_duration), - duration_type_filter: (filters, defaultFilters) => - filters.duration_type_filter !== defaultFilters.duration_type_filter, - console_search_query: (filters) => - filters.console_search_query ? filters.console_search_query.trim().length > 0 : false, - console_logs: (filters) => (filters.console_logs ? filters.console_logs.length > 0 : false), - filter_test_accounts: (filters) => filters.filter_test_accounts ?? false, -} - -export const addedAdvancedFilters = ( - filters: RecordingFilters | undefined, - defaultFilters: RecordingFilters -): boolean => { - // if there are no filters or if some filters are not present then the page is still booting up - if (!filters || filters.session_recording_duration === undefined || filters.date_from === undefined) { - return false - } - - // keeps results with the keys for printing when debugging - const checkResults = Object.keys(advancedFilterChecks).map((key) => ({ - key, - result: advancedFilterChecks[key](filters, defaultFilters), - })) - - // if any check is true, then this is an advanced filter - return checkResults.some((checkResult) => checkResult.result) -} - export const defaultPageviewPropertyEntityFilter = ( filters: RecordingFilters, property: string, @@ -194,12 +143,27 @@ export const defaultPageviewPropertyEntityFilter = ( } } +const capturePartialFilters = (filters: Partial): void => { + // capture only the partial filters applied (not the full filters object) + // take each key from the filter and change it to `partial_filter_chosen_${key}` + const partialFilters = Object.keys(filters).reduce((acc, key) => { + acc[`partial_filter_chosen_${key}`] = filters[key] + return acc + }, {}) + + posthog.capture('recording list filters changed', { + ...partialFilters, + }) +} + export interface SessionRecordingPlaylistLogicProps { logicKey?: string personUUID?: PersonUUID updateSearchParams?: boolean autoPlay?: boolean - filters?: RecordingFilters + hideSimpleFilters?: boolean + advancedFilters?: RecordingFilters + simpleFilters?: RecordingFilters onFiltersChange?: (filters: RecordingFilters) => void pinnedRecordings?: (SessionRecordingType | string)[] onPinnedChange?: (recording: SessionRecordingType, pinned: boolean) => void @@ -232,9 +196,9 @@ export const sessionRecordingsPlaylistLogic = kea) => ({ filters }), + setAdvancedFilters: (filters: Partial) => ({ filters }), + setSimpleFilters: (filters: SimpleFiltersType) => ({ filters }), setShowFilters: (showFilters: boolean) => ({ showFilters }), - setShowAdvancedFilters: (showAdvancedFilters: boolean) => ({ showAdvancedFilters }), setShowSettings: (showSettings: boolean) => ({ showSettings }), resetFilters: true, setSelectedRecordingId: (id: SessionRecordingType['id'] | null) => ({ @@ -250,8 +214,11 @@ export const sessionRecordingsPlaylistLogic = kea ({ show }), }), propsChanged(({ actions, props }, oldProps) => { - if (!objectsEqual(props.filters, oldProps.filters)) { - props.filters ? actions.setFilters(props.filters) : actions.resetFilters() + if (!objectsEqual(props.advancedFilters, oldProps.advancedFilters)) { + actions.setAdvancedFilters(props.advancedFilters || {}) + } + if (!objectsEqual(props.simpleFilters, oldProps.simpleFilters)) { + actions.setSimpleFilters(props.simpleFilters || {}) } // If the defined list changes, we need to call the loader to either load the new items or change the list @@ -374,7 +341,6 @@ export const sessionRecordingsPlaylistLogic = kea (show === undefined ? !state : show), }, ], - unusableEventsInFilter: [ [] as string[], { @@ -385,14 +351,24 @@ export const sessionRecordingsPlaylistLogic = kea ({ + setSimpleFilters: (state, { filters }) => ({ ...state, ...filters, }), - resetFilters: () => null, + resetFilters: () => DEFAULT_SIMPLE_RECORDING_FILTERS, + }, + ], + advancedFilters: [ + props.advancedFilters ?? getDefaultFilters(props.personUUID), + { + setAdvancedFilters: (state, { filters }) => ({ + ...state, + ...filters, + }), + resetFilters: () => getDefaultFilters(props.personUUID), }, ], showFilters: [ @@ -415,20 +391,6 @@ export const sessionRecordingsPlaylistLogic = kea false, }, ], - showAdvancedFilters: [ - addedAdvancedFilters(props.filters, getDefaultFilters(props.personUUID)), - { - persist: true, - }, - { - setFilters: (showingAdvancedFilters, { filters }) => { - return addedAdvancedFilters(filters, getDefaultFilters(props.personUUID)) - ? true - : showingAdvancedFilters - }, - setShowAdvancedFilters: (_, { showAdvancedFilters }) => showAdvancedFilters, - }, - ], sessionRecordings: [ [] as SessionRecordingType[], { @@ -490,7 +452,8 @@ export const sessionRecordingsPlaylistLogic = kea true, loadSessionRecordingSuccess: () => false, - setFilters: () => false, + setAdvancedFilters: () => false, + setSimpleFilters: () => false, loadNext: () => false, loadPrev: () => false, }, @@ -501,22 +464,16 @@ export const sessionRecordingsPlaylistLogic = kea { + setSimpleFilters: ({ filters }) => { actions.loadSessionRecordings() props.onFiltersChange?.(values.filters) - - // capture only the partial filters applied (not the full filters object) - // take each key from the filter and change it to `partial_filter_chosen_${key}` - const partialFilters = Object.keys(filters).reduce((acc, key) => { - acc[`partial_filter_chosen_${key}`] = filters[key] - return acc - }, {}) - - posthog.capture('recording list filters changed', { - ...partialFilters, - showing_advanced_filters: values.showAdvancedFilters, - }) - + capturePartialFilters(filters) + actions.loadEventsHaveSessionId() + }, + setAdvancedFilters: ({ filters }) => { + actions.loadSessionRecordings() + props.onFiltersChange?.(values.filters) + capturePartialFilters(filters) actions.loadEventsHaveSessionId() }, @@ -549,38 +506,14 @@ export const sessionRecordingsPlaylistLogic = kea [(_, props) => props], (props): SessionRecordingPlaylistLogicProps => props], - shouldShowEmptyState: [ - (s) => [ - s.sessionRecordings, - s.customFilters, - s.sessionRecordingsResponseLoading, - s.sessionRecordingsAPIErrored, - (_, props) => props.personUUID, - ], - ( - sessionRecordings, - customFilters, - sessionRecordingsResponseLoading, - sessionRecordingsAPIErrored, - personUUID - ): boolean => { - return ( - !sessionRecordingsAPIErrored && - !sessionRecordingsResponseLoading && - sessionRecordings.length === 0 && - !customFilters && - !personUUID - ) - }, - ], filters: [ - (s) => [s.customFilters, (_, props) => props.personUUID], - (customFilters, personUUID): RecordingFilters => { - const defaultFilters = getDefaultFilters(personUUID) + (s) => [s.simpleFilters, s.advancedFilters], + (simpleFilters, advancedFilters): RecordingFilters => { return { - ...defaultFilters, - ...customFilters, + ...advancedFilters, + events: [...(simpleFilters?.events || []), ...(advancedFilters?.events || [])], + properties: [...(simpleFilters?.properties || []), ...(advancedFilters?.properties || [])], } }, ], @@ -620,6 +553,7 @@ export const sessionRecordingsPlaylistLogic = kea [s.selectedRecordingId, s.recordings, (_, props) => props.autoPlay], (selectedRecordingId, recordings, autoPlay): SessionRecordingId | undefined => { @@ -630,12 +564,14 @@ export const sessionRecordingsPlaylistLogic = kea [s.activeSessionRecordingId, s.recordings], (activeSessionRecordingId, recordings): SessionRecordingType | undefined => { return recordings.find((rec) => rec.id === activeSessionRecordingId) }, ], + nextSessionRecording: [ (s) => [s.activeSessionRecording, s.recordings, s.autoplayDirection], (activeSessionRecording, recordings, autoplayDirection): Partial | undefined => { @@ -648,10 +584,12 @@ export const sessionRecordingsPlaylistLogic = kea [s.sessionRecordingsResponse], (sessionRecordingsResponse) => sessionRecordingsResponse.has_next, ], + totalFiltersCount: [ (s) => [s.filters, (_, props) => props.personUUID], (filters, personUUID) => { @@ -665,17 +603,11 @@ export const sessionRecordingsPlaylistLogic = kea [s.filters, (_, props) => props.personUUID], - (filters, personUUID) => { - const defaultFilters = getDefaultFilters(personUUID) - return addedAdvancedFilters(filters, defaultFilters) - }, - ], otherRecordings: [ (s) => [s.sessionRecordings, s.hideViewedRecordings, s.pinnedRecordings, s.selectedRecordingId], @@ -729,40 +661,45 @@ export const sessionRecordingsPlaylistLogic = kea { const params: Params = objectClean({ - filters: values.customFilters ?? undefined, + simpleFilters: values.simpleFilters ?? undefined, + advancedFilters: values.advancedFilters ?? undefined, sessionRecordingId: values.selectedRecordingId ?? undefined, }) - // We used to have sessionRecordingId in the hash, so we keep it there for backwards compatibility - if (router.values.hashParams.sessionRecordingId) { - delete router.values.hashParams.sessionRecordingId - } - return [router.values.location.pathname, params, router.values.hashParams, { replace }] } return { setSelectedRecordingId: () => buildURL(false), - setFilters: () => buildURL(true), + setAdvancedFilters: () => buildURL(true), + setSimpleFilters: () => buildURL(true), resetFilters: () => buildURL(true), } }), urlToAction(({ actions, values, props }) => { - const urlToAction = (_: any, params: Params, hashParams: Params): void => { + const urlToAction = (_: any, params: Params): void => { if (!props.updateSearchParams) { return } - // We changed to have the sessionRecordingId in the query params, but it used to be in the hash so backwards compatibility - const nulledSessionRecordingId = params.sessionRecordingId ?? hashParams.sessionRecordingId ?? null + const nulledSessionRecordingId = params.sessionRecordingId ?? null if (nulledSessionRecordingId !== values.selectedRecordingId) { actions.setSelectedRecordingId(nulledSessionRecordingId) } - if (params.filters) { - if (!equal(params.filters, values.customFilters)) { - actions.setFilters(params.filters) + if (params.simpleFilters || params.advancedFilters) { + if (params.simpleFilters && !equal(params.simpleFilters, values.simpleFilters)) { + actions.setSimpleFilters(params.simpleFilters) + } + if (params.advancedFilters && !equal(params.advancedFilters, values.advancedFilters)) { + actions.setAdvancedFilters(params.advancedFilters) + } + // support links that might still contain the old `filters` key + } else if (params.filters) { + if (!equal(params.filters, values.filters)) { + actions.setAdvancedFilters(params.filters) + actions.setSimpleFilters(DEFAULT_SIMPLE_RECORDING_FILTERS) } } } diff --git a/frontend/src/scenes/settings/settingsSceneLogic.test.ts b/frontend/src/scenes/settings/settingsSceneLogic.test.ts new file mode 100644 index 0000000000000..9f615d5549717 --- /dev/null +++ b/frontend/src/scenes/settings/settingsSceneLogic.test.ts @@ -0,0 +1,26 @@ +import { router } from 'kea-router' +import { expectLogic } from 'kea-test-utils' + +import { initKeaTests } from '~/test/init' + +import { settingsSceneLogic } from './settingsSceneLogic' + +describe('settingsSceneLogic', () => { + let logic: ReturnType + + beforeEach(async () => { + initKeaTests() + logic = settingsSceneLogic() + logic.mount() + }) + + it('reads filters from the URL', async () => { + router.actions.push('/settings/project-product-analytics', {}, { 'person-display-name': true }) + + await expectLogic(logic).toMatchValues({ + selectedLevel: 'project', + }) + + expect(router.values.hashParams).toEqual({ 'person-display-name': true }) + }) +}) diff --git a/frontend/src/scenes/settings/settingsSceneLogic.ts b/frontend/src/scenes/settings/settingsSceneLogic.ts index 996da3c22c32c..0009d10272ead 100644 --- a/frontend/src/scenes/settings/settingsSceneLogic.ts +++ b/frontend/src/scenes/settings/settingsSceneLogic.ts @@ -1,5 +1,5 @@ import { connect, kea, path, selectors } from 'kea' -import { actionToUrl, urlToAction } from 'kea-router' +import { actionToUrl, router, urlToAction } from 'kea-router' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { capitalizeFirstLetter } from 'lib/utils' import { Scene } from 'scenes/sceneTypes' @@ -60,10 +60,10 @@ export const settingsSceneLogic = kea([ actionToUrl(({ values }) => ({ selectLevel({ level }) { - return [urls.settings(level)] + return [urls.settings(level), router.values.searchParams, router.values.hashParams] }, selectSection({ section }) { - return [urls.settings(section)] + return [urls.settings(section), router.values.searchParams, router.values.hashParams] }, selectSetting({ setting }) { const url = urls.settings(values.selectedSectionId ?? values.selectedLevel, setting) diff --git a/frontend/src/scenes/surveys/SurveyEdit.tsx b/frontend/src/scenes/surveys/SurveyEdit.tsx index 7da14d222bbc7..bf49e1ebfbbdc 100644 --- a/frontend/src/scenes/surveys/SurveyEdit.tsx +++ b/frontend/src/scenes/surveys/SurveyEdit.tsx @@ -78,6 +78,7 @@ export default function SurveyEdit(): JSX.Element { onChange={(section) => { setSelectedSection(section) }} + className="bg-bg-light" panels={[ { key: SurveyEditSection.Presentation, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 4b3709121fb00..86b690eec3758 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -874,10 +874,6 @@ export interface RecordingFilters { filter_test_accounts?: boolean } -export interface LocalRecordingFilters extends RecordingFilters { - new_entity?: Record[] -} - export interface SessionRecordingsResponse { results: SessionRecordingType[] has_next: boolean diff --git a/posthog/session_recordings/queries/session_replay_events.py b/posthog/session_recordings/queries/session_replay_events.py index 8667a26d97d66..f6ebb417df84b 100644 --- a/posthog/session_recordings/queries/session_replay_events.py +++ b/posthog/session_recordings/queries/session_replay_events.py @@ -69,9 +69,9 @@ def get_metadata( session_id """ query = query.format( - optional_timestamp_clause="AND min_first_timestamp >= %(recording_start_time)s" - if recording_start_time - else "" + optional_timestamp_clause=( + "AND min_first_timestamp >= %(recording_start_time)s" if recording_start_time else "" + ) ) replay_response: List[Tuple] = sync_execute(