diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index c243cf67ba628..d9cf4918b917f 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -10148,6 +10148,21 @@ "required": ["k", "t"], "type": "object" }, + "RecordingOrder": { + "enum": [ + "duration", + "recording_duration", + "inactive_seconds", + "active_seconds", + "start_time", + "console_error_count", + "click_count", + "keypress_count", + "mouse_activity_count", + "activity_score" + ], + "type": "string" + }, "RecordingPropertyFilter": { "additionalProperties": false, "properties": { @@ -10237,35 +10252,7 @@ "$ref": "#/definitions/FilterLogicalOperator" }, "order": { - "anyOf": [ - { - "$ref": "#/definitions/DurationType" - }, - { - "const": "start_time", - "type": "string" - }, - { - "const": "console_error_count", - "type": "string" - }, - { - "const": "click_count", - "type": "string" - }, - { - "const": "keypress_count", - "type": "string" - }, - { - "const": "mouse_activity_count", - "type": "string" - }, - { - "const": "activity_score", - "type": "string" - } - ] + "$ref": "#/definitions/RecordingOrder" }, "person_uuid": { "type": "string" @@ -10289,7 +10276,7 @@ "type": "object" } }, - "required": ["kind", "order"], + "required": ["kind"], "type": "object" }, "RecordingsQueryResponse": { diff --git a/frontend/src/queries/schema.ts b/frontend/src/queries/schema.ts index 92b6d52f251db..43bf20362478a 100644 --- a/frontend/src/queries/schema.ts +++ b/frontend/src/queries/schema.ts @@ -11,7 +11,6 @@ import { ChartDisplayType, CohortPropertyFilter, CountPerActorMathType, - DurationType, EventPropertyFilter, EventType, FeaturePropertyFilter, @@ -311,6 +310,18 @@ export interface RecordingsQueryResponse { has_next: boolean } +export type RecordingOrder = + | 'duration' + | 'recording_duration' + | 'inactive_seconds' + | 'active_seconds' + | 'start_time' + | 'console_error_count' + | 'click_count' + | 'keypress_count' + | 'mouse_activity_count' + | 'activity_score' + export interface RecordingsQuery extends DataNode { kind: NodeKind.RecordingsQuery date_from?: string | null @@ -324,14 +335,7 @@ export interface RecordingsQuery extends DataNode { operand?: FilterLogicalOperator session_ids?: string[] person_uuid?: string - order: - | DurationType - | 'start_time' - | 'console_error_count' - | 'click_count' - | 'keypress_count' - | 'mouse_activity_count' - | 'activity_score' + order?: RecordingOrder limit?: integer offset?: integer user_modified_filters?: Record diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx index 61f59ac76efed..c1d5479aa0635 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx @@ -10,7 +10,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { useNotebookNode } from 'scenes/notebooks/Nodes/NotebookNodeContext' import { urls } from 'scenes/urls' -import { RecordingsQuery } from '~/queries/schema' +import { RecordingOrder } from '~/queries/schema' import { RecordingUniversalFilters, ReplayTabs, SessionRecordingType } from '~/types' import { RecordingsUniversalFilters } from '../filters/RecordingsUniversalFilters' @@ -31,7 +31,7 @@ function SortedBy({ filters: RecordingUniversalFilters setFilters: (filters: Partial) => void }): JSX.Element { - const simpleSortingOptions: LemonSelectSection = { + const simpleSortingOptions: LemonSelectSection = { options: [ { value: 'start_time', @@ -47,7 +47,7 @@ function SortedBy({ }, ], } - const detailedSortingOptions: LemonSelectSection = { + const detailedSortingOptions: LemonSelectSection = { options: [ { label: 'Longest', diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index 35201c4d0f4cb..bc238b4a35e14 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -16,7 +16,7 @@ import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { objectClean, objectsEqual } from 'lib/utils' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { NodeKind, RecordingsQuery, RecordingsQueryResponse } from '~/queries/schema' +import { NodeKind, RecordingOrder, RecordingsQuery, RecordingsQueryResponse } from '~/queries/schema' import { EntityTypes, FilterLogicalOperator, @@ -225,17 +225,11 @@ function combineLegacyRecordingFilters( } } -function sortRecordings(recordings: SessionRecordingType[], order: RecordingsQuery['order']): SessionRecordingType[] { - const orderKey: - | 'recording_duration' - | 'activity_score' - | 'active_seconds' - | 'inactive_seconds' - | 'console_error_count' - | 'click_count' - | 'keypress_count' - | 'mouse_activity_count' - | 'start_time' = order === 'duration' ? 'recording_duration' : order +function sortRecordings( + recordings: SessionRecordingType[], + order: RecordingsQuery['order'] | 'duration' = 'start_time' +): SessionRecordingType[] { + const orderKey: RecordingOrder = order === 'duration' ? 'recording_duration' : order return recordings.sort((a, b) => { const orderA = a[orderKey] @@ -741,6 +735,9 @@ export const sessionRecordingsPlaylistLogic = kea { - const { setVariable } = useActions(sessionReplayTemplatesLogic(props)) + const { setVariable, resetVariable } = useActions(sessionReplayTemplatesLogic(props)) useMountedLogic(actionsModel) return variable.type === 'pageview' ? ( @@ -80,7 +80,10 @@ const SingleTemplateVariable = ({ {variable.name} setVariable({ ...variable, value: e })} + value={variable.value} + onChange={(e) => + e ? setVariable({ ...variable, value: e }) : resetVariable({ ...variable, value: undefined }) + } size="small" /> @@ -91,7 +94,7 @@ const SingleTemplateVariable = ({ rootKey={`session-recordings-${variable.key}`} group={{ type: FilterLogicalOperator.And, - values: [], + values: variable.filterGroup ? [variable.filterGroup] : [], }} taxonomicGroupTypes={ variable.type === 'event' @@ -103,9 +106,13 @@ const SingleTemplateVariable = ({ : [] } onChange={(thisFilterGroup) => { - variable.type === 'flag' - ? setVariable({ ...variable, value: (thisFilterGroup.values[0] as FeaturePropertyFilter).key }) - : setVariable({ ...variable, filterGroup: thisFilterGroup.values[0] }) + if (thisFilterGroup.values.length === 0) { + resetVariable({ ...variable, filterGroup: undefined }) + } else if (variable.type === 'flag') { + setVariable({ ...variable, value: (thisFilterGroup.values[0] as FeaturePropertyFilter).key }) + } else { + setVariable({ ...variable, filterGroup: thisFilterGroup.values[0] }) + } }} > { const { navigate } = useActions(sessionReplayTemplatesLogic(props)) - const { variables, areAnyVariablesTouched } = useValues(sessionReplayTemplatesLogic(props)) + const { variables, canApplyFilters } = useValues(sessionReplayTemplatesLogic(props)) return (
- {variables.map((variable) => ( - - ))} + {variables + .filter((v) => !v.noTouch) + .map((variable) => ( + + ))}
navigate()} type="primary" className="mt-2" - disabledReason={ - !areAnyVariablesTouched ? 'Please set a value for at least one variable' : undefined - } + disabledReason={!canApplyFilters ? 'Please set a value for at least one variable' : undefined} > Apply filters @@ -145,14 +152,14 @@ const TemplateVariables = (props: RecordingTemplateCardProps): JSX.Element => { } const RecordingTemplateCard = (props: RecordingTemplateCardProps): JSX.Element => { - const { showVariables, hideVariables, navigate } = useActions(sessionReplayTemplatesLogic(props)) - const { variablesVisible, editableVariables } = useValues(sessionReplayTemplatesLogic(props)) + const { showVariables, hideVariables } = useActions(sessionReplayTemplatesLogic(props)) + const { variablesVisible } = useValues(sessionReplayTemplatesLogic(props)) return ( { - editableVariables.length > 0 ? showVariables() : navigate() + showVariables() }} closeable={variablesVisible} onClose={hideVariables} diff --git a/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx b/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx index 47f5418bb5825..04520ca8affdb 100644 --- a/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx +++ b/frontend/src/scenes/session-recordings/templates/availableTemplates.tsx @@ -177,6 +177,7 @@ export const replayTemplates: ReplayTemplateType[] = [ description: 'Watch all recent replays, and see where users are getting stuck.', variables: [], categories: ['More'], + order: 'start_time', icon: , }, { @@ -223,4 +224,12 @@ export const replayTemplates: ReplayTemplateType[] = [ categories: ['More'], icon: , }, + { + key: 'activity-score', + name: 'Most active users', + description: 'Watch recordings of the most active sessions. Lots of valuable insights, guaranteed!', + order: 'activity_score', + categories: ['More'], + icon: , + }, ] diff --git a/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx b/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx index 10e14b031d66b..f44023016729f 100644 --- a/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx +++ b/frontend/src/scenes/session-recordings/templates/sessionRecordingTemplatesLogic.tsx @@ -1,5 +1,8 @@ -import { actions, events, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import clsx from 'clsx' +import { actions, connect, events, kea, key, listeners, path, props, reducers, selectors } from 'kea' import { router } from 'kea-router' +import posthog from 'posthog-js' +import { teamLogic } from 'scenes/teamLogic' import { urls } from 'scenes/urls' import { @@ -64,20 +67,35 @@ export const sessionReplayTemplatesLogic = kea( path(() => ['scenes', 'session-recordings', 'templates', 'sessionReplayTemplatesLogic']), props({} as ReplayTemplateLogicPropsType), key((props) => `${props.category}-${props.template.key}`), + connect({ + values: [teamLogic, ['currentTeam']], + }), actions({ - setVariables: (variables: ReplayTemplateVariableType[]) => ({ variables }), + setVariables: (variables?: ReplayTemplateVariableType[]) => ({ variables }), setVariable: (variable: ReplayTemplateVariableType) => ({ variable }), + resetVariable: (variable: ReplayTemplateVariableType) => ({ variable }), navigate: true, showVariables: true, hideVariables: true, }), - reducers(({ props }) => ({ + reducers(({ props, values }) => ({ variables: [ - props.template.variables, + props.template.variables ?? [], + { + persist: true, + storageKey: clsx( + 'session-recordings.templates.variables', + values.currentTeam?.id, + props.category, + props.template.key + ), + }, { - setVariables: (_, { variables }) => variables, + setVariables: (_, { variables }) => variables ?? [], setVariable: (state, { variable }) => state.map((v) => (v.key === variable.key ? { ...variable, touched: true } : v)), + resetVariable: (state, { variable }) => + state.map((v) => (v.key === variable.key ? { ...variable, touched: false } : v)), }, ], variablesVisible: [ @@ -125,21 +143,31 @@ export const sessionReplayTemplatesLogic = kea( return filterGroup }, ], + canApplyFilters: [ + (s) => [s.variables, s.areAnyVariablesTouched], + (variables, areAnyVariablesTouched) => areAnyVariablesTouched || variables.length === 0, + ], areAnyVariablesTouched: [ (s) => [s.variables], (variables) => variables.some((v) => v.touched) || variables.some((v) => v.noTouch), ], editableVariables: [(s) => [s.variables], (variables) => variables.filter((v) => !v.noTouch)], }), - listeners(({ values }) => ({ + listeners(({ values, props }) => ({ navigate: () => { + posthog.capture('session replay template used', { + template: props.template.key, + category: props.category, + }) const filterGroup = values.variables.length > 0 ? values.filterGroup : undefined - router.actions.push(urls.replay(ReplayTabs.Home, filterGroup)) + router.actions.push(urls.replay(ReplayTabs.Home, filterGroup, undefined, props.template.order)) }, })), - events(({ actions, props }) => ({ + events(({ actions, props, values }) => ({ afterMount: () => { - actions.setVariables(props.template.variables) + if (values.variables.length === 0) { + actions.setVariables(props.template.variables) + } }, })), ]) diff --git a/frontend/src/scenes/urls.ts b/frontend/src/scenes/urls.ts index 05f0372b7f8c8..a6fe7fcbaca9b 100644 --- a/frontend/src/scenes/urls.ts +++ b/frontend/src/scenes/urls.ts @@ -110,10 +110,16 @@ export const urls = { savedInsights: (tab?: string): string => `/insights${tab ? `?tab=${tab}` : ''}`, webAnalytics: (): string => `/web`, - replay: (tab?: ReplayTabs, filters?: Partial, sessionRecordingId?: string): string => + replay: ( + tab?: ReplayTabs, + filters?: Partial, + sessionRecordingId?: string, + order?: string + ): string => combineUrl(tab ? `/replay/${tab}` : '/replay/home', { ...(filters ? { filters } : {}), ...(sessionRecordingId ? { sessionRecordingId } : {}), + ...(order ? { order } : {}), }).url, replayPlaylist: (id: string): string => `/replay/playlists/${id}`, replaySingle: (id: string): string => `/replay/${id}`, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index fd3c9e4f5405a..305a9c7bac623 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -40,6 +40,7 @@ import type { InsightVizNode, Node, QueryStatus, + RecordingOrder, RecordingsQuery, } from './queries/schema' import { NodeKind } from './queries/schema' @@ -4635,9 +4636,10 @@ export type ReplayTemplateType = { key: string name: string description: string - variables: ReplayTemplateVariableType[] + variables?: ReplayTemplateVariableType[] categories: ReplayTemplateCategory[] icon?: React.ReactNode + order?: RecordingOrder } export type ReplayTemplateCategory = 'B2B' | 'B2C' | 'More' diff --git a/posthog/schema.py b/posthog/schema.py index b77997f9f74f5..8587684bdd436 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -1086,6 +1086,19 @@ class QueryTiming(BaseModel): t: float = Field(..., description="Time in seconds. Shortened to 't' to save on data.") +class RecordingOrder(StrEnum): + DURATION = "duration" + RECORDING_DURATION = "recording_duration" + INACTIVE_SECONDS = "inactive_seconds" + ACTIVE_SECONDS = "active_seconds" + START_TIME = "start_time" + CONSOLE_ERROR_COUNT = "console_error_count" + CLICK_COUNT = "click_count" + KEYPRESS_COUNT = "keypress_count" + MOUSE_ACTIVITY_COUNT = "mouse_activity_count" + ACTIVITY_SCORE = "activity_score" + + class RecordingPropertyFilter(BaseModel): model_config = ConfigDict( extra="forbid", @@ -5255,7 +5268,7 @@ class RecordingsQuery(BaseModel): ) offset: Optional[int] = None operand: Optional[FilterLogicalOperator] = None - order: Union[DurationType, str] + order: Optional[RecordingOrder] = None person_uuid: Optional[str] = None properties: Optional[ list[