From 03093a8ad9cddb98ec23802cbdb49a63085c5880 Mon Sep 17 00:00:00 2001 From: Ben White Date: Tue, 10 Oct 2023 14:50:17 +0200 Subject: [PATCH] feat: Reworked Playlist UI with Notebook support (#17802) --- .../api/session-recordings/recording.json | 3 +- .../test/test_session_recording_playlist.py | 3 - ...nes-app-notebooks--recordings-playlist.png | Bin 89038 -> 81559 bytes frontend/src/lib/api.ts | 19 +- frontend/src/lib/components/PropertyIcon.tsx | 14 +- frontend/src/lib/components/TZLabel/index.tsx | 12 +- .../scenes/notebooks/Nodes/NodeWrapper.tsx | 10 +- .../notebooks/Nodes/NotebookNodePlaylist.tsx | 82 +- .../notebooks/Nodes/NotebookNodeRecording.tsx | 2 +- .../notebooks/Nodes/notebookNodeLogic.ts | 8 + .../src/scenes/notebooks/Nodes/utils.test.tsx | 136 ++++ frontend/src/scenes/notebooks/Nodes/utils.tsx | 9 + .../notebooks/Notebook/Notebook.stories.tsx | 1 - .../notebooks/Notebook/NotebookSidebar.tsx | 4 +- frontend/src/scenes/persons/PersonScene.tsx | 6 +- .../project-homepage/RecentRecordings.tsx | 4 +- .../session-recordings/SessionRecordings.tsx | 22 +- .../__mocks__/recording_meta.json | 3 +- .../__mocks__/recording_snapshots.json | 2 +- .../sessionRecordingFilePlaybackLogic.ts | 1 - .../player/PlayerFrameOverlay.tsx | 21 +- .../player/PlayerMetaLinks.tsx | 44 +- .../player/PlayerUpNext.tsx | 24 +- .../player/SessionRecordingPlayer.tsx | 12 +- .../sessionRecordingPlayerLogic.test.ts.snap | 3 - .../player/controller/PlayerController.tsx | 26 +- .../player/controller/seekbarLogic.ts | 8 +- .../player/inspector/playerInspectorLogic.ts | 6 +- .../player/playerMetaLogic.ts | 8 +- .../playlist-popover/PlaylistPopover.tsx | 9 +- .../playlist-popover/playlistPopoverLogic.ts | 57 +- .../player/sessionRecordingDataLogic.test.ts | 1 - .../player/sessionRecordingDataLogic.ts | 31 +- .../sessionRecordingPlayerLogic.test.ts | 20 +- .../player/sessionRecordingPlayerLogic.ts | 67 +- .../playlist/SessionRecordingPreview.tsx | 68 +- .../playlist/SessionRecordingsList.tsx | 186 ----- .../playlist/SessionRecordingsPlaylist.scss | 69 +- .../playlist/SessionRecordingsPlaylist.tsx | 513 ++++++------- .../SessionRecordingsPlaylistItem.tsx | 245 ------ .../SessionRecordingsPlaylistScene.tsx | 29 +- .../playlist/playlistUtils.ts | 2 +- .../sessionRecordingsListLogic.test.ts | 518 ------------- .../playlist/sessionRecordingsListLogic.ts | 661 ---------------- .../sessionRecordingsPlaylistLogic.test.ts | 534 +++++++++++-- .../sessionRecordingsPlaylistLogic.ts | 726 +++++++++++++++--- ...essionRecordingsPlaylistSceneLogic.test.ts | 81 ++ .../sessionRecordingsPlaylistSceneLogic.ts | 168 ++++ frontend/src/styles/utilities.scss | 15 + frontend/src/types.ts | 5 +- package.json | 1 + pnpm-lock.yaml | 36 + .../filters/mixins/session_recordings.py | 17 +- .../models/session_recording.py | 15 +- .../test/test_session_replay_events.py | 1 - .../session_recording_api.py | 47 +- .../test/test_session_recordings.py | 14 +- posthog/tasks/usage_report.py | 1 - 58 files changed, 2204 insertions(+), 2426 deletions(-) create mode 100644 frontend/src/scenes/notebooks/Nodes/utils.test.tsx delete mode 100644 frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx delete mode 100644 frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx delete mode 100644 frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts delete mode 100644 frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts create mode 100644 frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts create mode 100644 frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts diff --git a/cypress/fixtures/api/session-recordings/recording.json b/cypress/fixtures/api/session-recordings/recording.json index f0d1fe5e96186..fee091b9cf527 100644 --- a/cypress/fixtures/api/session-recordings/recording.json +++ b/cypress/fixtures/api/session-recordings/recording.json @@ -31,6 +31,5 @@ "created_at": "2023-07-11T14:21:33.883000Z", "uuid": "01894554-925a-0000-11d9-a44d69b426d7" }, - "storage": "clickhouse", - "pinned_count": 0 + "storage": "object_storage" } diff --git a/ee/session_recordings/test/test_session_recording_playlist.py b/ee/session_recordings/test/test_session_recording_playlist.py index ddbb4d1195bca..6569988518639 100644 --- a/ee/session_recordings/test/test_session_recording_playlist.py +++ b/ee/session_recordings/test/test_session_recording_playlist.py @@ -207,7 +207,6 @@ def test_get_pinned_recordings_for_playlist(self): ).json() assert len(result["results"]) == 2 assert {x["id"] for x in result["results"]} == {session_one, session_two} - assert {x["pinned_count"] for x in result["results"]} == {1, 1} @patch("ee.session_recordings.session_recording_extensions.object_storage.list_objects") @patch("ee.session_recordings.session_recording_extensions.object_storage.copy_objects") @@ -313,11 +312,9 @@ def test_add_remove_static_playlist_items(self): session_recording_obj_1 = SessionRecording.get_or_build(team=self.team, session_id=recording1_session_id) assert session_recording_obj_1 - assert session_recording_obj_1.pinned_count == 1 session_recording_obj_2 = SessionRecording.get_or_build(team=self.team, session_id=recording2_session_id) assert session_recording_obj_2 - assert session_recording_obj_2.pinned_count == 2 # Delete playlist items result = self.client.delete( diff --git a/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png b/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png index 5356f64192dd5c507d7ec210b49f126612348457..06866ae4e420f1af34bcb59b821e4930dfc01d1d 100644 GIT binary patch delta 46362 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

`-j1R-}q5fM0Z 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%0D^Ze_LoMG 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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 86f09892d6082..f6c1111ce6b68 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1293,14 +1293,21 @@ const api = { }, recordings: { - async list(params: string): Promise { - return await new ApiRequest().recordings().withQueryString(params).get() + async list(params: Record): Promise { + return await new ApiRequest().recordings().withQueryString(toParams(params)).get() }, async getMatchingEvents(params: string): Promise<{ results: string[] }> { return await new ApiRequest().recordingMatchingEvents().withQueryString(params).get() }, - async get(recordingId: SessionRecordingType['id'], params: string): Promise { - return await new ApiRequest().recording(recordingId).withQueryString(params).get() + async get( + recordingId: SessionRecordingType['id'], + params: Record = {} + ): Promise { + return await new ApiRequest().recording(recordingId).withQueryString(toParams(params)).get() + }, + + async persist(recordingId: SessionRecordingType['id']): Promise<{ success: boolean }> { + return await new ApiRequest().recording(recordingId).withAction('persist').create() }, async delete(recordingId: SessionRecordingType['id']): Promise<{ success: boolean }> { @@ -1360,12 +1367,12 @@ const api = { async listPlaylistRecordings( playlistId: SessionRecordingPlaylistType['short_id'], - params: string + params: Record = {} ): Promise { return await new ApiRequest() .recordingPlaylist(playlistId) .withAction('recordings') - .withQueryString(params) + .withQueryString(toParams(params)) .get() }, diff --git a/frontend/src/lib/components/PropertyIcon.tsx b/frontend/src/lib/components/PropertyIcon.tsx index b19f1f7ffb2d0..91f351b7f1093 100644 --- a/frontend/src/lib/components/PropertyIcon.tsx +++ b/frontend/src/lib/components/PropertyIcon.tsx @@ -20,7 +20,7 @@ import { import clsx from 'clsx' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { countryCodeToFlag } from 'scenes/insights/views/WorldMap' -import { ReactNode } from 'react' +import { HTMLAttributes, ReactNode } from 'react' export const PROPERTIES_ICON_MAP = { $browser: { @@ -61,7 +61,7 @@ interface PropertyIconProps { value?: string className?: string noTooltip?: boolean - onClick?: (property: string, value?: string) => void + onClick?: HTMLAttributes['onClick'] tooltipTitle?: (property: string, value?: string) => ReactNode // Tooltip title will default to `value` } @@ -87,15 +87,7 @@ export function PropertyIcon({ } const content = ( -

{ - if (onClick) { - e.stopPropagation() - onClick(property, value) - } - }} - className={clsx('inline-flex items-center', className)} - > +
{icon}
) diff --git a/frontend/src/lib/components/TZLabel/index.tsx b/frontend/src/lib/components/TZLabel/index.tsx index 6374826df2aa9..859552654342b 100644 --- a/frontend/src/lib/components/TZLabel/index.tsx +++ b/frontend/src/lib/components/TZLabel/index.tsx @@ -7,13 +7,13 @@ import { teamLogic } from '../../../scenes/teamLogic' import { dayjs } from 'lib/dayjs' import clsx from 'clsx' import React, { useCallback, useEffect, useMemo, useState } from 'react' -import { LemonButton, LemonDivider, LemonDropdown } from '@posthog/lemon-ui' +import { LemonButton, LemonDivider, LemonDropdown, LemonDropdownProps } from '@posthog/lemon-ui' import { IconSettings } from 'lib/lemon-ui/icons' import { urls } from 'scenes/urls' const BASE_OUTPUT_FORMAT = 'ddd, MMM D, YYYY h:mm A' -interface TZLabelRawProps { +export type TZLabelProps = Omit & { time: string | dayjs.Dayjs showSeconds?: boolean formatDate?: string @@ -26,7 +26,7 @@ interface TZLabelRawProps { const TZLabelPopoverContent = React.memo(function TZLabelPopoverContent({ showSeconds, time, -}: Pick & { time: dayjs.Dayjs }): JSX.Element { +}: Pick & { time: dayjs.Dayjs }): JSX.Element { const DATE_OUTPUT_FORMAT = !showSeconds ? BASE_OUTPUT_FORMAT : `${BASE_OUTPUT_FORMAT}:ss` const { currentTeam } = useValues(teamLogic) const { reportTimezoneComponentViewed } = useActions(eventUsageLogic) @@ -86,7 +86,8 @@ function TZLabelRaw({ showPopover = true, noStyles = false, className, -}: TZLabelRawProps): JSX.Element { + ...dropdownProps +}: TZLabelProps): JSX.Element { const parsedTime = useMemo(() => (dayjs.isDayjs(time) ? time : dayjs(time)), [time]) const format = useCallback(() => { @@ -120,9 +121,10 @@ function TZLabelRaw({ if (showPopover) { return ( } > {innerContent} diff --git a/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx b/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx index c1d0042c319b7..09c15805f292f 100644 --- a/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NodeWrapper.tsx @@ -78,7 +78,6 @@ function NodeWrapper({ }: NodeWrapperProps & NotebookNodeViewProps): JSX.Element { const mountedNotebookLogic = useMountedLogic(notebookLogic) const { isEditable, editingNodeId } = useValues(notebookLogic) - const { setEditingNodeId } = useActions(notebookLogic) // nodeId can start null, but should then immediately be generated const nodeId = attributes.nodeId @@ -93,10 +92,11 @@ function NodeWrapper({ resizeable: resizeableOrGenerator, settings, startExpanded, + defaultTitle, } const nodeLogic = useMountedLogic(notebookNodeLogic(nodeLogicProps)) const { resizeable, expanded, actions } = useValues(nodeLogic) - const { setExpanded, deleteNode } = useActions(nodeLogic) + const { setExpanded, deleteNode, toggleEditing } = useActions(nodeLogic) const [ref, inView] = useInView({ triggerOnce: true }) const contentRef = useRef(null) @@ -185,11 +185,7 @@ function NodeWrapper({ <> {settings ? ( - setEditingNodeId( - editingNodeId === nodeId ? null : nodeId - ) - } + onClick={() => toggleEditing()} size="small" icon={} active={editingNodeId === nodeId} diff --git a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx index 36dbdd4d79d1b..c1236b2294eb1 100644 --- a/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx +++ b/frontend/src/scenes/notebooks/Nodes/NotebookNodePlaylist.tsx @@ -1,32 +1,28 @@ import { createPostHogWidgetNode } from 'scenes/notebooks/Nodes/NodeWrapper' import { FilterType, NotebookNodeType, RecordingFilters } from '~/types' import { - RecordingsLists, - SessionRecordingsPlaylistProps, -} from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' -import { + SessionRecordingPlaylistLogicProps, addedAdvancedFilters, getDefaultFilters, - sessionRecordingsListLogic, -} from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' + sessionRecordingsPlaylistLogic, +} from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { useActions, useValues } from 'kea' -import { SessionRecordingPlayer } from 'scenes/session-recordings/player/SessionRecordingPlayer' import { useEffect, useMemo, useState } from 'react' import { fromParamsGivenUrl } from 'lib/utils' -import { LemonButton } from '@posthog/lemon-ui' -import { IconChevronLeft } from 'lib/lemon-ui/icons' import { urls } from 'scenes/urls' import { notebookNodeLogic } from './notebookNodeLogic' import { JSONContent, NotebookNodeViewProps, NotebookNodeAttributeProperties } from '../Notebook/utils' import { SessionRecordingsFilters } from 'scenes/session-recordings/filters/SessionRecordingsFilters' import { ErrorBoundary } from '@sentry/react' +import { SessionRecordingsPlaylist } from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' import { sessionRecordingPlayerLogic } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' +import { IconComment } from 'lib/lemon-ui/icons' const Component = (props: NotebookNodeViewProps): JSX.Element => { - const { filters, nodeId } = props.attributes + const { filters, pinned, nodeId } = props.attributes const playerKey = `notebook-${nodeId}` - const recordingPlaylistLogicProps: SessionRecordingsPlaylistProps = useMemo( + const recordingPlaylistLogicProps: SessionRecordingPlaylistLogicProps = useMemo( () => ({ logicKey: playerKey, filters, @@ -37,16 +33,23 @@ const Component = (props: NotebookNodeViewProps) filters: newFilters, }) }, + pinnedRecordings: pinned, + onPinnedChange(recording, isPinned) { + props.updateAttributes({ + pinned: isPinned + ? [...(pinned || []), String(recording.id)] + : pinned?.filter((id) => id !== recording.id), + }) + }, }), - [playerKey, filters] + [playerKey, filters, pinned] ) - const { expanded } = useValues(notebookNodeLogic) - const { setActions, insertAfter, setMessageListeners, scrollIntoView } = useActions(notebookNodeLogic) + const { setActions, insertAfter, insertReplayCommentByTimestamp, setMessageListeners, scrollIntoView } = + useActions(notebookNodeLogic) - const logic = sessionRecordingsListLogic(recordingPlaylistLogicProps) - const { activeSessionRecording, nextSessionRecording, matchingEventsMatchType, sessionRecordings } = - useValues(logic) + const logic = sessionRecordingsPlaylistLogic(recordingPlaylistLogicProps) + const { activeSessionRecording } = useValues(logic) const { setSelectedRecordingId } = useActions(logic) useEffect(() => { @@ -54,7 +57,7 @@ const Component = (props: NotebookNodeViewProps) activeSessionRecording ? [ { - text: 'Pin replay', + text: 'View replay', onClick: () => { insertAfter({ type: NotebookNodeType.Recording, @@ -64,6 +67,15 @@ const Component = (props: NotebookNodeViewProps) }) }, }, + { + text: 'Comment', + icon: , + onClick: () => { + if (activeSessionRecording.id) { + insertReplayCommentByTimestamp(0, activeSessionRecording.id) + } + }, + }, ] : [] ) @@ -72,6 +84,7 @@ const Component = (props: NotebookNodeViewProps) useEffect(() => { setMessageListeners({ 'play-replay': ({ sessionRecordingId, time }) => { + // IDEA: We could add the desired start time here as a param, which is picked up by the player... setSelectedRecordingId(sessionRecordingId) scrollIntoView() @@ -83,32 +96,7 @@ const Component = (props: NotebookNodeViewProps) }) }, []) - if (!expanded) { - return
{sessionRecordings.length}+ recordings
- } - - const content = !activeSessionRecording?.id ? ( - - ) : ( - <> - } - onClick={() => setSelectedRecordingId(null)} - className="self-start" - /> - - - ) - - return
{content}
+ return } export const Settings = ({ @@ -141,6 +129,7 @@ export const Settings = ({ type NotebookNodePlaylistAttributes = { filters: RecordingFilters + pinned?: string[] } export const NotebookNodePlaylist = createPostHogWidgetNode({ @@ -153,11 +142,14 @@ export const NotebookNodePlaylist = createPostHogWidgetNode return !expanded ? (
{sessionPlayerMetaData ? ( - + ) : ( )} diff --git a/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts b/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts index f1cc6867a8c66..af8b15202d970 100644 --- a/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts +++ b/frontend/src/scenes/notebooks/Nodes/notebookNodeLogic.ts @@ -39,6 +39,7 @@ export type NotebookNodeLogicProps = { settings: NotebookNodeSettings messageListeners?: NotebookNodeMessagesListeners startExpanded: boolean + defaultTitle: string } & NotebookNodeAttributeProperties const computeResizeable = ( @@ -65,6 +66,7 @@ export const notebookNodeLogic = kea([ setNextNode: (node: Node | null) => ({ node }), deleteNode: true, selectNode: true, + toggleEditing: true, scrollIntoView: true, setMessageListeners: (listeners: NotebookNodeMessagesListeners) => ({ listeners }), }), @@ -117,6 +119,7 @@ export const notebookNodeLogic = kea([ notebookLogic: [(_, p) => [p.notebookLogic], (notebookLogic) => notebookLogic], nodeAttributes: [(_, p) => [p.attributes], (nodeAttributes) => nodeAttributes], settings: [(_, p) => [p.settings], (settings) => settings], + defaultTitle: [(_, p) => [p.defaultTitle], (title) => title], sendMessage: [ (s) => [s.messageListeners], @@ -200,6 +203,11 @@ export const notebookNodeLogic = kea([ updateAttributes: ({ attributes }) => { props.updateAttributes(attributes) }, + toggleEditing: () => { + props.notebookLogic.actions.setEditingNodeId( + props.notebookLogic.values.editingNodeId === props.nodeId ? null : props.nodeId + ) + }, })), afterMount(async (logic) => { diff --git a/frontend/src/scenes/notebooks/Nodes/utils.test.tsx b/frontend/src/scenes/notebooks/Nodes/utils.test.tsx new file mode 100644 index 0000000000000..af46f229b2cd8 --- /dev/null +++ b/frontend/src/scenes/notebooks/Nodes/utils.test.tsx @@ -0,0 +1,136 @@ +import { NodeViewProps } from '@tiptap/core' +import { useSyncedAttributes } from './utils' +import { renderHook, act } from '@testing-library/react-hooks' + +describe('notebook node utils', () => { + jest.useFakeTimers() + describe('useSyncedAttributes', () => { + const harness: { node: { attrs: Record }; updateAttributes: any } = { + node: { attrs: {} }, + updateAttributes: jest.fn((attrs) => { + harness.node.attrs = { ...harness.node.attrs, ...attrs } + }), + } + + const nodeViewProps = harness as unknown as NodeViewProps + + beforeEach(() => { + harness.node.attrs = { + foo: 'bar', + } + harness.updateAttributes.mockClear() + }) + + it('should set a default node ID', () => { + const { result } = renderHook(() => useSyncedAttributes(nodeViewProps)) + + expect(nodeViewProps.updateAttributes).not.toHaveBeenCalled() + + expect(result.current[0]).toEqual({ + nodeId: expect.any(String), + foo: 'bar', + }) + }) + + it('should do nothing if an attribute is unchanged', () => { + const { result } = renderHook(() => useSyncedAttributes(nodeViewProps)) + + expect(nodeViewProps.updateAttributes).not.toHaveBeenCalled() + + expect(result.current[0]).toMatchObject({ + foo: 'bar', + }) + + act(() => { + result.current[1]({ + foo: 'bar', + }) + }) + + jest.runOnlyPendingTimers() + + expect(nodeViewProps.updateAttributes).not.toHaveBeenCalled() + + expect(result.current[0]).toMatchObject({ + foo: 'bar', + }) + }) + + it('should call the update attributes function if changed', () => { + const { result, rerender } = renderHook(() => useSyncedAttributes(nodeViewProps)) + + expect(nodeViewProps.updateAttributes).not.toHaveBeenCalled() + + act(() => { + result.current[1]({ + foo: 'bar2', + }) + }) + + jest.runOnlyPendingTimers() + + expect(nodeViewProps.updateAttributes).toHaveBeenCalledWith({ + foo: 'bar2', + }) + + rerender() + + expect(result.current[0]).toMatchObject({ + foo: 'bar2', + }) + }) + + it('should stringify and parse content', () => { + harness.node.attrs = { + filters: { my: 'data' }, + number: 1, + } + const { result, rerender } = renderHook(() => useSyncedAttributes(nodeViewProps)) + + expect(result.current[0]).toEqual({ + nodeId: expect.any(String), + filters: { + my: 'data', + }, + number: 1, + }) + + act(() => { + result.current[1]({ + filters: { + my: 'changed data', + }, + }) + }) + + jest.runOnlyPendingTimers() + + expect(nodeViewProps.updateAttributes).toHaveBeenCalledWith({ + filters: '{"my":"changed data"}', + }) + + rerender() + + expect(result.current[0]).toEqual({ + nodeId: expect.any(String), + filters: { + my: 'changed data', + }, + number: 1, + }) + + harness.updateAttributes.mockClear() + + act(() => { + result.current[1]({ + filters: { + my: 'changed data', + }, + }) + }) + + jest.runOnlyPendingTimers() + expect(nodeViewProps.updateAttributes).not.toHaveBeenCalled() + }) + }) +}) diff --git a/frontend/src/scenes/notebooks/Nodes/utils.tsx b/frontend/src/scenes/notebooks/Nodes/utils.tsx index d3a96d59b23eb..70016ffc8f60d 100644 --- a/frontend/src/scenes/notebooks/Nodes/utils.tsx +++ b/frontend/src/scenes/notebooks/Nodes/utils.tsx @@ -129,6 +129,15 @@ export function useSyncedAttributes( }), {} ) + + const hasChanges = Object.keys(stringifiedAttrs).some( + (key) => previousNodeAttrs.current?.[key] !== stringifiedAttrs[key] + ) + + if (!hasChanges) { + return + } + // NOTE: queueMicrotask protects us from TipTap's flushSync calls, ensuring we never modify the state whilst the flush is happening queueMicrotask(() => props.updateAttributes(stringifiedAttrs)) }, diff --git a/frontend/src/scenes/notebooks/Notebook/Notebook.stories.tsx b/frontend/src/scenes/notebooks/Notebook/Notebook.stories.tsx index c91894a8d45ba..4424251929804 100644 --- a/frontend/src/scenes/notebooks/Notebook/Notebook.stories.tsx +++ b/frontend/src/scenes/notebooks/Notebook/Notebook.stories.tsx @@ -336,7 +336,6 @@ const meta: Meta = { uuid: '018a8a51-a3d3-0000-e8fa-94621f9ddd48', }, storage: 'clickhouse', - pinned_count: 0, }, ], has_next: false, diff --git a/frontend/src/scenes/notebooks/Notebook/NotebookSidebar.tsx b/frontend/src/scenes/notebooks/Notebook/NotebookSidebar.tsx index e94cff0b7f9a3..b909c5c340da0 100644 --- a/frontend/src/scenes/notebooks/Notebook/NotebookSidebar.tsx +++ b/frontend/src/scenes/notebooks/Notebook/NotebookSidebar.tsx @@ -31,12 +31,12 @@ export const NotebookSidebar = (): JSX.Element | null => { const Widgets = ({ logic }: { logic: BuiltLogic }): JSX.Element => { const { setEditingNodeId } = useActions(notebookLogic) - const { settings: Settings, nodeAttributes } = useValues(logic) + const { settings: Settings, nodeAttributes, defaultTitle } = useValues(logic) const { updateAttributes, selectNode } = useActions(logic) return ( diff --git a/frontend/src/scenes/persons/PersonScene.tsx b/frontend/src/scenes/persons/PersonScene.tsx index e6c7a1036bc05..2a7a277deda10 100644 --- a/frontend/src/scenes/persons/PersonScene.tsx +++ b/frontend/src/scenes/persons/PersonScene.tsx @@ -23,7 +23,6 @@ import { teamLogic } from 'scenes/teamLogic' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { PersonDeleteModal } from 'scenes/persons/PersonDeleteModal' import { SpinnerOverlay } from 'lib/lemon-ui/Spinner/Spinner' -import { SessionRecordingsPlaylist } from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' import { NotFound } from 'lib/components/NotFound' import { RelatedFeatureFlags } from './RelatedFeatureFlags' import { Query } from '~/queries/Query/Query' @@ -34,6 +33,7 @@ import { IconInfo } from 'lib/lemon-ui/icons' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' import { PersonDashboard } from './PersonDashboard' import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' +import { SessionRecordingsPlaylist } from 'scenes/session-recordings/playlist/SessionRecordingsPlaylist' export const scene: SceneExport = { component: PersonScene, @@ -235,7 +235,9 @@ export function PersonScene(): JSX.Element | null {
) : null} - +
+ +
), }, diff --git a/frontend/src/scenes/project-homepage/RecentRecordings.tsx b/frontend/src/scenes/project-homepage/RecentRecordings.tsx index 1445a53dd1900..085f1d6f92a62 100644 --- a/frontend/src/scenes/project-homepage/RecentRecordings.tsx +++ b/frontend/src/scenes/project-homepage/RecentRecordings.tsx @@ -4,7 +4,7 @@ import { useActions, useValues } from 'kea' import './ProjectHomepage.scss' import { CompactList } from 'lib/components/CompactList/CompactList' import { ProfilePicture } from 'lib/lemon-ui/ProfilePicture' -import { sessionRecordingsListLogic } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { urls } from 'scenes/urls' import { SessionRecordingType } from '~/types' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' @@ -48,7 +48,7 @@ export function RecordingRow({ recording }: RecordingRowProps): JSX.Element { export function RecentRecordings(): JSX.Element { const { currentTeam } = useValues(teamLogic) - const sessionRecordingsListLogicInstance = sessionRecordingsListLogic({ logicKey: 'projectHomepage' }) + const sessionRecordingsListLogicInstance = sessionRecordingsPlaylistLogic({ logicKey: 'projectHomepage' }) const { sessionRecordings, sessionRecordingsResponseLoading } = useValues(sessionRecordingsListLogicInstance) return ( diff --git a/frontend/src/scenes/session-recordings/SessionRecordings.tsx b/frontend/src/scenes/session-recordings/SessionRecordings.tsx index 89da8f50a2a29..4259f055aa210 100644 --- a/frontend/src/scenes/session-recordings/SessionRecordings.tsx +++ b/frontend/src/scenes/session-recordings/SessionRecordings.tsx @@ -3,10 +3,9 @@ import { teamLogic } from 'scenes/teamLogic' import { useActions, useValues } from 'kea' import { urls } from 'scenes/urls' import { SceneExport } from 'scenes/sceneTypes' -import { SessionRecordingsPlaylist } from './playlist/SessionRecordingsPlaylist' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' import { LemonButton } from '@posthog/lemon-ui' -import { AvailableFeature, ReplayTabs } from '~/types' +import { AvailableFeature, NotebookNodeType, ReplayTabs } from '~/types' import { SavedSessionRecordingPlaylists } from './saved-playlists/SavedSessionRecordingPlaylists' import { humanFriendlyTabName, sessionRecordingsLogic } from './sessionRecordingsLogic' import { Spinner } from 'lib/lemon-ui/Spinner/Spinner' @@ -20,9 +19,11 @@ import { eventUsageLogic } from 'lib/utils/eventUsageLogic' import { sceneLogic } from 'scenes/sceneLogic' import { savedSessionRecordingPlaylistsLogic } from './saved-playlists/savedSessionRecordingPlaylistsLogic' import { LemonTabs } from 'lib/lemon-ui/LemonTabs' -import { sessionRecordingsListLogic } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { VersionCheckerBanner } from 'lib/components/VersionChecker/VersionCheckerBanner' import { authorizedUrlListLogic, AuthorizedUrlListType } from 'lib/components/AuthorizedUrlList/authorizedUrlListLogic' +import { SessionRecordingsPlaylist } from './playlist/SessionRecordingsPlaylist' +import { NotebookSelectButton } from 'scenes/notebooks/NotebookSelectButton/NotebookSelectButton' export function SessionsRecordings(): JSX.Element { const { currentTeam } = useValues(teamLogic) @@ -46,7 +47,7 @@ export function SessionsRecordings(): JSX.Element { }) // NB this relies on `updateSearchParams` being the only prop needed to pick the correct "Recent" tab list logic - const { filters, totalFiltersCount } = useValues(sessionRecordingsListLogic({ updateSearchParams: true })) + const { filters, totalFiltersCount } = useValues(sessionRecordingsPlaylistLogic({ updateSearchParams: true })) const saveFiltersPlaylistHandler = useAsyncHandler(async () => { await createPlaylist({ filters }, true) reportRecordingPlaylistCreated('filters') @@ -61,6 +62,15 @@ export function SessionsRecordings(): JSX.Element { <> {tab === ReplayTabs.Recent && !recordingsDisabled && ( <> + ) : tab === ReplayTabs.Recent ? ( - +
+ +
) : tab === ReplayTabs.Playlists ? ( ) : tab === ReplayTabs.FilePlayback ? ( diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json b/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json index 1812acfa655e6..aef53e6400da1 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_meta.json @@ -19,6 +19,5 @@ "created_at": "2023-05-01T14:46:20.838000Z", "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0" }, - "storage": "clickhouse", - "pinned_count": 0 + "storage": "object_storage" } diff --git a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json index e33e115c9241b..cadcbb8a537cd 100644 --- a/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json +++ b/frontend/src/scenes/session-recordings/__mocks__/recording_snapshots.json @@ -1317,5 +1317,5 @@ { "type": 3, "data": { "source": 2, "type": 6, "id": 33 }, "timestamp": 1682952392745 } ] }, - "storage": "clickhouse" + "storage": "object_storage" } diff --git a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts index 7322097752790..d5edf19da253a 100644 --- a/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts +++ b/frontend/src/scenes/session-recordings/file-playback/sessionRecordingFilePlaybackLogic.ts @@ -149,7 +149,6 @@ export const sessionRecordingFilePlaybackLogic = kea -} - const PlayerFrameOverlayContent = ({ currentPlayerState, }: { @@ -82,7 +75,7 @@ const PlayerFrameOverlayContent = ({ } export function PlayerFrameOverlay(): JSX.Element { - const { currentPlayerState } = useValues(sessionRecordingPlayerLogic) + const { currentPlayerState, playlistLogic } = useValues(sessionRecordingPlayerLogic) const { togglePlayPause } = useActions(sessionRecordingPlayerLogic) const [interrupted, setInterrupted] = useState(false) @@ -95,7 +88,13 @@ export function PlayerFrameOverlay(): JSX.Element { onMouseOut={() => setInterrupted(false)} > - setInterrupted(false)} /> + {playlistLogic ? ( + setInterrupted(false)} + /> + ) : undefined}
) } diff --git a/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx b/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx index 8eabc0fbf452f..b93793c4783b5 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx +++ b/frontend/src/scenes/session-recordings/player/PlayerMetaLinks.tsx @@ -4,7 +4,7 @@ import { } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { useActions, useValues } from 'kea' import { LemonButton, LemonButtonProps } from 'lib/lemon-ui/LemonButton' -import { IconComment, IconDelete, IconJournalPlus, IconLink } from 'lib/lemon-ui/icons' +import { IconComment, IconDelete, IconJournalPlus, IconLink, IconPinFilled, IconPinOutline } from 'lib/lemon-ui/icons' import { openPlayerShareDialog } from 'scenes/session-recordings/player/share/PlayerShare' import { PlaylistPopoverButton } from './playlist-popover/PlaylistPopover' import { LemonDialog } from 'lib/lemon-ui/LemonDialog' @@ -14,7 +14,7 @@ import { useNotebookNode } from 'scenes/notebooks/Nodes/notebookNodeLogic' export function PlayerMetaLinks(): JSX.Element { const { sessionRecordingId, logicProps } = useValues(sessionRecordingPlayerLogic) - const { setPause, deleteRecording } = useActions(sessionRecordingPlayerLogic) + const { setPause, deleteRecording, maybePersistRecording } = useActions(sessionRecordingPlayerLogic) const nodeLogic = useNotebookNode() const getCurrentPlayerTime = (): number => { @@ -83,19 +83,33 @@ export function PlayerMetaLinks(): JSX.Element { Share - {nodeLogic ? ( - nodeLogic.props.nodeType !== NotebookNodeType.Recording ? ( - } - size="small" - onClick={() => { - nodeLogic.actions.insertAfter({ - type: NotebookNodeType.Recording, - attrs: { id: sessionRecordingId }, - }) - }} - /> - ) : null + {nodeLogic?.props.nodeType === NotebookNodeType.RecordingPlaylist ? ( + } + size="small" + onClick={() => { + nodeLogic.actions.insertAfter({ + type: NotebookNodeType.Recording, + attrs: { id: sessionRecordingId }, + }) + }} + /> + ) : null} + + {logicProps.setPinned ? ( + { + if (nodeLogic && !logicProps.pinned) { + // If we are in a node, then pinning should persist the recording + maybePersistRecording() + } + + logicProps.setPinned?.(!logicProps.pinned) + }} + size="small" + tooltip={logicProps.pinned ? 'Unpin from this list' : 'Pin to this list'} + icon={logicProps.pinned ? : } + /> ) : ( Pin diff --git a/frontend/src/scenes/session-recordings/player/PlayerUpNext.tsx b/frontend/src/scenes/session-recordings/player/PlayerUpNext.tsx index 13a22f604e8e7..54a4c5df45c6f 100644 --- a/frontend/src/scenes/session-recordings/player/PlayerUpNext.tsx +++ b/frontend/src/scenes/session-recordings/player/PlayerUpNext.tsx @@ -1,36 +1,34 @@ import './PlayerUpNext.scss' import { sessionRecordingPlayerLogic } from './sessionRecordingPlayerLogic' import { CSSTransition } from 'react-transition-group' -import { useActions, useValues } from 'kea' +import { BuiltLogic, useActions, useValues } from 'kea' import { IconPlay } from 'lib/lemon-ui/icons' import { Tooltip } from 'lib/lemon-ui/Tooltip' import { useEffect, useRef, useState } from 'react' import clsx from 'clsx' -import { router } from 'kea-router' +import { sessionRecordingsPlaylistLogicType } from '../playlist/sessionRecordingsPlaylistLogicType' export interface PlayerUpNextProps { + playlistLogic: BuiltLogic interrupted?: boolean clearInterrupted?: () => void } -export function PlayerUpNext({ interrupted, clearInterrupted }: PlayerUpNextProps): JSX.Element | null { +export function PlayerUpNext({ interrupted, clearInterrupted, playlistLogic }: PlayerUpNextProps): JSX.Element | null { const timeoutRef = useRef() - const { endReached, logicProps } = useValues(sessionRecordingPlayerLogic) + const { endReached } = useValues(sessionRecordingPlayerLogic) const { reportNextRecordingTriggered } = useActions(sessionRecordingPlayerLogic) const [animate, setAnimate] = useState(false) - const nextSessionRecording = logicProps.nextSessionRecording + const { nextSessionRecording } = useValues(playlistLogic) + const { setSelectedRecordingId } = useActions(playlistLogic) const goToRecording = (automatic: boolean): void => { + if (!nextSessionRecording?.id) { + return + } reportNextRecordingTriggered(automatic) - router.actions.push( - router.values.currentLocation.pathname, - { - ...router.values.currentLocation.searchParams, - sessionRecordingId: nextSessionRecording?.id, - }, - router.values.currentLocation.hashParams - ) + setSelectedRecordingId(nextSessionRecording.id) } useEffect(() => { diff --git a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx index cf1e141c36fd0..3097dbe5e7119 100644 --- a/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx +++ b/frontend/src/scenes/session-recordings/player/SessionRecordingPlayer.tsx @@ -21,7 +21,7 @@ import { RecordingNotFound } from 'scenes/session-recordings/player/RecordingNot import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' import { PlayerFrameOverlay } from './PlayerFrameOverlay' import { SessionRecordingPlayerExplorer } from './view-explorer/SessionRecordingPlayerExplorer' -import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' export interface SessionRecordingPlayerProps extends SessionRecordingPlayerLogicProps { noMeta?: boolean @@ -43,13 +43,14 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. sessionRecordingData, playerKey, noMeta = false, - recordingStartTime, // While optional, including recordingStartTime allows the underlying ClickHouse query to be much faster matchingEventsMatchType, noBorder = false, noInspector = false, autoPlay = true, - nextSessionRecording, + playlistLogic, mode = SessionRecordingPlayerMode.Standard, + pinned, + setPinned, } = props const playerRef = useRef(null) @@ -59,11 +60,12 @@ export function SessionRecordingPlayer(props: SessionRecordingPlayerProps): JSX. playerKey, matchingEventsMatchType, sessionRecordingData, - recordingStartTime, autoPlay, - nextSessionRecording, + playlistLogic, mode, playerRef, + pinned, + setPinned, } const { incrementClickCount, diff --git a/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap b/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap index 4dd12a28be780..d3bca952828b2 100644 --- a/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap +++ b/frontend/src/scenes/session-recordings/player/__snapshots__/sessionRecordingPlayerLogic.test.ts.snap @@ -18,7 +18,6 @@ exports[`sessionRecordingPlayerLogic loading session core loads metadata and sna }, "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0", }, - "pinnedCount": 0, "segments": [ { "durationMs": 11868, @@ -51,7 +50,6 @@ exports[`sessionRecordingPlayerLogic loading session core loads metadata and sna }, "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0", }, - "pinnedCount": 0, "segments": [ { "durationMs": 7255, @@ -2131,7 +2129,6 @@ exports[`sessionRecordingPlayerLogic loading session core loads metadata only by }, "uuid": "0187d7c7-61b7-0000-d6a1-59b207080ac0", }, - "pinnedCount": 0, "segments": [ { "durationMs": 11868, diff --git a/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx b/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx index a56d8a3737768..b37f874c67861 100644 --- a/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx +++ b/frontend/src/scenes/session-recordings/player/controller/PlayerController.tsx @@ -13,6 +13,7 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip' import clsx from 'clsx' import { playerSettingsLogic } from '../playerSettingsLogic' import { More } from 'lib/lemon-ui/LemonButton/More' +import { KeyboardShortcut } from '~/layout/navigation-3000/components/KeyboardShortcut' export function PlayerController(): JSX.Element { const { currentPlayerState, logicProps, isFullScreen } = useValues(sessionRecordingPlayerLogic) @@ -24,6 +25,10 @@ export function PlayerController(): JSX.Element { const mode = logicProps.mode ?? SessionRecordingPlayerMode.Standard + const showPause = [SessionPlayerState.PLAY, SessionPlayerState.SKIP, SessionPlayerState.BUFFER].includes( + currentPlayerState + ) + return (
@@ -31,14 +36,19 @@ export function PlayerController(): JSX.Element {
- - {[SessionPlayerState.PLAY, SessionPlayerState.SKIP, SessionPlayerState.BUFFER].includes( - currentPlayerState - ) ? ( - - ) : ( - - )} + + + {showPause ? 'Pause' : 'Play'} + + + } + > + {showPause ? : }
diff --git a/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts b/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts index 65756888007a5..b736e418e4663 100644 --- a/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts +++ b/frontend/src/scenes/session-recordings/player/controller/seekbarLogic.ts @@ -2,7 +2,7 @@ import { MutableRefObject } from 'react' import { actions, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' import type { seekbarLogicType } from './seekbarLogicType' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { clamp } from 'lib/utils' @@ -11,9 +11,9 @@ import { getXPos, InteractEvent, ReactInteractEvent, THUMB_OFFSET, THUMB_SIZE } export const seekbarLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'seekbarLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ values: [sessionRecordingPlayerLogic(props), ['sessionPlayerData', 'currentPlayerTime']], actions: [sessionRecordingPlayerLogic(props), ['seekToTime', 'startScrub', 'endScrub', 'setCurrentTimestamp']], })), diff --git a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts index e2c542749fd39..5b4cec5c21332 100644 --- a/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts +++ b/frontend/src/scenes/session-recordings/player/inspector/playerInspectorLogic.ts @@ -9,7 +9,7 @@ import { } from '~/types' import type { playerInspectorLogicType } from './playerInspectorLogicType' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' -import { SessionRecordingLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' +import { SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' import { sessionRecordingDataLogic } from '../sessionRecordingDataLogic' import FuseClass from 'fuse.js' import { Dayjs, dayjs } from 'lib/dayjs' @@ -17,7 +17,7 @@ import { getKeyMapping } from 'lib/taxonomy' import { eventToDescription, objectsEqual, toParams } from 'lib/utils' import { eventWithTime } from '@rrweb/types' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { loaders } from 'kea-loaders' import api from 'lib/api' @@ -120,7 +120,7 @@ export type InspectorListItemPerformance = InspectorListItemBase & { export type InspectorListItem = InspectorListItemEvent | InspectorListItemConsole | InspectorListItemPerformance -export interface PlayerInspectorLogicProps extends SessionRecordingLogicProps { +export interface PlayerInspectorLogicProps extends SessionRecordingPlayerLogicProps { matchingEventsMatchType?: MatchingEventsMatchType } diff --git a/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts b/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts index 9d458cb0d7c19..9c55ee262de76 100644 --- a/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts +++ b/frontend/src/scenes/session-recordings/player/playerMetaLogic.ts @@ -2,7 +2,7 @@ import { connect, kea, key, listeners, path, props, selectors } from 'kea' import type { playerMetaLogicType } from './playerMetaLogicType' import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { - SessionRecordingLogicProps, + SessionRecordingPlayerLogicProps, sessionRecordingPlayerLogic, } from 'scenes/session-recordings/player/sessionRecordingPlayerLogic' import { eventWithTime } from '@rrweb/types' @@ -12,9 +12,9 @@ import { sessionRecordingsListPropertiesLogic } from '../playlist/sessionRecordi export const playerMetaLogic = kea([ path((key) => ['scenes', 'session-recordings', 'player', 'playerMetaLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ values: [ sessionRecordingDataLogic(props), [ diff --git a/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx b/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx index 6b18669a915a1..f862c183bad59 100644 --- a/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx +++ b/frontend/src/scenes/session-recordings/player/playlist-popover/PlaylistPopover.tsx @@ -13,7 +13,7 @@ import { sessionRecordingPlayerLogic } from '../sessionRecordingPlayerLogic' import { playlistPopoverLogic } from './playlistPopoverLogic' export function PlaylistPopoverButton(props: LemonButtonProps): JSX.Element { - const { sessionRecordingId, logicProps, sessionPlayerData } = useValues(sessionRecordingPlayerLogic) + const { sessionRecordingId, logicProps } = useValues(sessionRecordingPlayerLogic) const logic = playlistPopoverLogic(logicProps) const { playlistsLoading, @@ -23,12 +23,13 @@ export function PlaylistPopoverButton(props: LemonButtonProps): JSX.Element { allPlaylists, currentPlaylistsLoading, modifyingPlaylist, + pinnedCount, } = useValues(logic) const { setSearchQuery, setNewFormShowing, setShowPlaylistPopover, addToPlaylist, removeFromPlaylist } = useActions(logic) return ( - + setShowPlaylistPopover(false)} @@ -97,10 +98,6 @@ export function PlaylistPopoverButton(props: LemonButtonProps): JSX.Element { } > {playlist.name || playlist.derived_name} - - {logicProps.playlistShortId === playlist.short_id && ( - (current) - )} ([ path((key) => ['scenes', 'session-recordings', 'player', 'playlist-popover', 'playlistPopoverLogic', key]), - props({} as SessionRecordingLogicProps), - key((props: SessionRecordingLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), - connect((props: SessionRecordingLogicProps) => ({ + props({} as SessionRecordingPlayerLogicProps), + key((props: SessionRecordingPlayerLogicProps) => `${props.playerKey}-${props.sessionRecordingId}`), + connect((props: SessionRecordingPlayerLogicProps) => ({ actions: [ sessionRecordingPlayerLogic(props), ['setPause'], - sessionRecordingDataLogic(props), - ['addDiffToRecordingMetaPinnedCount'], eventUsageLogic, ['reportRecordingPinnedToList', 'reportRecordingPlaylistCreated'], ], @@ -38,10 +34,6 @@ export const playlistPopoverLogic = kea([ removeFromPlaylist: (playlist: SessionRecordingPlaylistType) => ({ playlist }), setNewFormShowing: (show: boolean) => ({ show }), setShowPlaylistPopover: (show: boolean) => ({ show }), - updateRecordingsPinnedCounts: ( - diffCount: number, - playlistShortId?: SessionRecordingPlaylistType['short_id'] - ) => ({ diffCount, playlistShortId }), })), loaders(({ values, props, actions }) => ({ playlists: { @@ -144,39 +136,18 @@ export const playlistPopoverLogic = kea([ actions.setPause() } }, - - addToPlaylistSuccess: ({ payload }) => { - actions.updateRecordingsPinnedCounts(1, payload?.playlist?.short_id) - }, - - removeFromPlaylistSuccess: ({ payload }) => { - actions.updateRecordingsPinnedCounts(-1, payload?.playlist?.short_id) - }, - - updateRecordingsPinnedCounts: ({ diffCount, playlistShortId }) => { - actions.addDiffToRecordingMetaPinnedCount(diffCount) - - // Handles locally updating recordings sidebar so that we don't have to call expensive load recordings every time. - if (!!playlistShortId && sessionRecordingsListLogic.isMounted({ playlistShortId })) { - // On playlist page - sessionRecordingsListLogic({ playlistShortId }).actions.loadAllRecordings() - } else { - // In any other context (recent recordings, single modal, single recording page) - sessionRecordingsListLogic.findMounted({ updateSearchParams: true })?.actions?.loadAllRecordings() - } - }, })), selectors(() => ({ allPlaylists: [ - (s) => [s.playlists, s.currentPlaylists, s.searchQuery, (_, props) => props.playlistShortId], - (playlists, currentPlaylists, searchQuery, playlistShortId) => { + (s) => [s.playlists, s.currentPlaylists, s.searchQuery], + (playlists, currentPlaylists, searchQuery) => { const otherPlaylists = searchQuery ? playlists : playlists.filter((x) => !currentPlaylists.find((y) => x.short_id === y.short_id)) const selectedPlaylists = !searchQuery ? currentPlaylists : [] - let results: { + const results: { selected: boolean playlist: SessionRecordingPlaylistType }[] = [ @@ -190,15 +161,13 @@ export const playlistPopoverLogic = kea([ })), ] - // If props.playlistShortId exists put it at the beginning of the list - if (playlistShortId) { - results = results.sort((a, b) => - a.playlist.short_id == playlistShortId ? -1 : b.playlist.short_id == playlistShortId ? 1 : 0 - ) - } - return results }, ], + pinnedCount: [(s) => [s.currentPlaylists], (currentPlaylists) => currentPlaylists.length], })), + + afterMount(({ actions }) => { + actions.loadPlaylistsForRecording() + }), ]) diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts index b59f9566fb9a7..69ed720f2e524 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.test.ts @@ -129,7 +129,6 @@ describe('sessionRecordingDataLogic', () => { start: undefined, end: undefined, durationMs: 0, - pinnedCount: 0, segments: [], person: null, snapshotsByWindowId: {}, diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts index d74828f8de776..3ea0ead150f1a 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingDataLogic.ts @@ -134,7 +134,6 @@ const generateRecordingReportDurations = ( export interface SessionRecordingDataLogicProps { sessionRecordingId: SessionRecordingId - recordingStartTime?: string } export const sessionRecordingDataLogic = kea([ @@ -152,7 +151,6 @@ export const sessionRecordingDataLogic = kea([ setFilters: (filters: Partial) => ({ filters }), loadRecordingMeta: true, maybeLoadRecordingMeta: true, - addDiffToRecordingMetaPinnedCount: (diffCount: number) => ({ diffCount }), loadRecordingSnapshotsV1: (nextUrl?: string) => ({ nextUrl }), loadRecordingSnapshotsV2: (source?: SessionRecordingSnapshotSource) => ({ source }), loadRecordingSnapshots: true, @@ -162,6 +160,8 @@ export const sessionRecordingDataLogic = kea([ loadFullEventData: (event: RecordingEventType) => ({ event }), reportViewed: true, reportUsageIfFullyLoaded: true, + persistRecording: true, + maybePersistRecording: true, }), reducers(() => ({ filters: [ @@ -315,6 +315,16 @@ export const sessionRecordingDataLogic = kea([ values.loadedFromBlobStorage ) }, + + maybePersistRecording: () => { + if (values.sessionPlayerMetaDataLoading) { + return + } + + if (values.sessionPlayerMetaData?.storage === 'object_storage') { + actions.persistRecording() + } + }, })), loaders(({ values, props, cache }) => ({ sessionPlayerMetaData: { @@ -323,23 +333,24 @@ export const sessionRecordingDataLogic = kea([ if (!props.sessionRecordingId) { return null } - const params = toParams({ + const response = await api.recordings.get(props.sessionRecordingId, { save_view: true, - recording_start_time: props.recordingStartTime, }) - const response = await api.recordings.get(props.sessionRecordingId, params) breakpoint() return response }, - addDiffToRecordingMetaPinnedCount: ({ diffCount }) => { + + persistRecording: async (_, breakpoint) => { if (!values.sessionPlayerMetaData) { return null } + breakpoint(100) + await api.recordings.persist(props.sessionRecordingId) return { ...values.sessionPlayerMetaData, - pinned_count: Math.max(values.sessionPlayerMetaData.pinned_count ?? 0 + diffCount, 0), + storage: 'object_storage_lts', } }, }, @@ -357,12 +368,9 @@ export const sessionRecordingDataLogic = kea([ } await breakpoint(1) - const params = toParams({ - recording_start_time: props.recordingStartTime, - }) const apiUrl = nextUrl || - `api/projects/${values.currentTeamId}/session_recordings/${props.sessionRecordingId}/snapshots?${params}` + `api/projects/${values.currentTeamId}/session_recordings/${props.sessionRecordingId}/snapshots` const response: SessionRecordingSnapshotResponse = await api.get(apiUrl) breakpoint() @@ -595,7 +603,6 @@ export const sessionRecordingDataLogic = kea([ durationMs, fullyLoaded ): SessionPlayerData => ({ - pinnedCount: meta?.pinned_count ?? 0, person: meta?.person ?? null, start, end, diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts index 740d04a34d101..fb9494dac817b 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.test.ts @@ -12,7 +12,7 @@ import { resumeKeaLoadersErrors, silenceKeaLoadersErrors } from '~/initKea' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import api from 'lib/api' import { MOCK_TEAM_ID } from 'lib/api.mock' -import { sessionRecordingsListLogic } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { router } from 'kea-router' import { urls } from 'scenes/urls' @@ -146,12 +146,12 @@ describe('sessionRecordingPlayerLogic', () => { describe('delete session recording', () => { it('on playlist page', async () => { silenceKeaLoadersErrors() - const listLogic = sessionRecordingsListLogic({ playlistShortId: 'playlist_id' }) + const listLogic = sessionRecordingsPlaylistLogic({}) listLogic.mount() logic = sessionRecordingPlayerLogic({ sessionRecordingId: '3', playerKey: 'test', - playlistShortId: 'playlist_id', + playlistLogic: listLogic, }) logic.mount() jest.spyOn(api, 'delete') @@ -165,7 +165,7 @@ describe('sessionRecordingPlayerLogic', () => { listLogic.actionCreators.setSelectedRecordingId(null), ]) .toNotHaveDispatchedActions([ - sessionRecordingsListLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, + sessionRecordingsPlaylistLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, ]) expect(api.delete).toHaveBeenCalledWith(`api/projects/${MOCK_TEAM_ID}/session_recordings/3`) @@ -174,9 +174,13 @@ describe('sessionRecordingPlayerLogic', () => { it('on any other recordings page with a list', async () => { silenceKeaLoadersErrors() - const listLogic = sessionRecordingsListLogic({ updateSearchParams: true }) + const listLogic = sessionRecordingsPlaylistLogic({ updateSearchParams: true }) listLogic.mount() - logic = sessionRecordingPlayerLogic({ sessionRecordingId: '3', playerKey: 'test' }) + logic = sessionRecordingPlayerLogic({ + sessionRecordingId: '3', + playerKey: 'test', + playlistLogic: listLogic, + }) logic.mount() jest.spyOn(api, 'delete') @@ -204,7 +208,7 @@ describe('sessionRecordingPlayerLogic', () => { }) .toDispatchActions(['deleteRecording']) .toNotHaveDispatchedActions([ - sessionRecordingsListLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, + sessionRecordingsPlaylistLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, ]) .toFinishAllListeners() @@ -225,7 +229,7 @@ describe('sessionRecordingPlayerLogic', () => { }) .toDispatchActions(['deleteRecording']) .toNotHaveDispatchedActions([ - sessionRecordingsListLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, + sessionRecordingsPlaylistLogic({ updateSearchParams: true }).actionTypes.loadAllRecordings, ]) .toFinishAllListeners() diff --git a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts index 9168f013d914c..cfa711c3b4445 100644 --- a/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts +++ b/frontend/src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts @@ -1,18 +1,27 @@ -import { actions, afterMount, beforeUnmount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { + BuiltLogic, + actions, + afterMount, + beforeUnmount, + connect, + kea, + key, + listeners, + path, + props, + reducers, + selectors, +} from 'kea' import { windowValues } from 'kea-window-values' import type { sessionRecordingPlayerLogicType } from './sessionRecordingPlayerLogicType' import { Replayer } from 'rrweb' import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { - AvailableFeature, - RecordingSegment, - SessionPlayerData, - SessionPlayerState, - SessionRecordingId, - SessionRecordingType, -} from '~/types' +import { AvailableFeature, RecordingSegment, SessionPlayerData, SessionPlayerState } from '~/types' import { getBreakpoint } from 'lib/utils/responsiveUtils' -import { sessionRecordingDataLogic } from 'scenes/session-recordings/player/sessionRecordingDataLogic' +import { + SessionRecordingDataLogicProps, + sessionRecordingDataLogic, +} from 'scenes/session-recordings/player/sessionRecordingDataLogic' import { deleteRecording } from './utils/playerUtils' import { playerSettingsLogic } from './playerSettingsLogic' import { clamp, downloadFile, fromParamsGivenUrl } from 'lib/utils' @@ -20,10 +29,7 @@ import { lemonToast } from '@posthog/lemon-ui' import { delay } from 'kea-test-utils' import { userLogic } from 'scenes/userLogic' import { openBillingPopupModal } from 'scenes/billing/BillingPopup' -import { - MatchingEventsMatchType, - sessionRecordingsListLogic, -} from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { MatchingEventsMatchType } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { router } from 'kea-router' import { urls } from 'scenes/urls' import { wrapConsole } from 'lib/utils/wrapConsole' @@ -37,6 +43,7 @@ import { ReplayPlugin } from 'rrweb/typings/types' import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { FEATURE_FLAGS } from 'lib/constants' +import type { sessionRecordingsPlaylistLogicType } from '../playlist/sessionRecordingsPlaylistLogicType' export const PLAYBACK_SPEEDS = [0.5, 1, 2, 3, 4, 8, 16] export const ONE_FRAME_MS = 100 // We don't really have frames but this feels granular enough @@ -70,21 +77,16 @@ export enum SessionRecordingPlayerMode { Preview = 'preview', } -// This is the basic props used by most sub-logics -export interface SessionRecordingLogicProps { - sessionRecordingId: SessionRecordingId +export interface SessionRecordingPlayerLogicProps extends SessionRecordingDataLogicProps { playerKey: string -} - -export interface SessionRecordingPlayerLogicProps extends SessionRecordingLogicProps { sessionRecordingData?: SessionPlayerData - playlistShortId?: string matchingEventsMatchType?: MatchingEventsMatchType - recordingStartTime?: string - nextSessionRecording?: Partial + playlistLogic?: BuiltLogic autoPlay?: boolean mode?: SessionRecordingPlayerMode playerRef?: RefObject + pinned?: boolean + setPinned?: (pinned: boolean) => void } const isMediaElementPlaying = (element: HTMLMediaElement): boolean => @@ -120,6 +122,7 @@ export const sessionRecordingPlayerLogic = kea( 'loadRecordingSnapshotsSuccess', 'loadRecordingSnapshotsFailure', 'loadRecordingMetaSuccess', + 'maybePersistRecording', ], playerSettingsLogic, ['setSpeed', 'setSkipInactivitySetting'], @@ -331,6 +334,7 @@ export const sessionRecordingPlayerLogic = kea( // Prop references for use by other logics sessionRecordingId: [() => [(_, props) => props], (props): string => props.sessionRecordingId], logicProps: [() => [(_, props) => props], (props): SessionRecordingPlayerLogicProps => props], + playlistLogic: [() => [(_, props) => props], (props) => props.playlistLogic], currentPlayerState: [ (s) => [ @@ -910,19 +914,10 @@ export const sessionRecordingPlayerLogic = kea( deleteRecording: async () => { await deleteRecording(props.sessionRecordingId) - // Handles locally updating recordings sidebar so that we don't have to call expensive load recordings every time. - const listLogic = - !!props.playlistShortId && - sessionRecordingsListLogic.isMounted({ playlistShortId: props.playlistShortId }) - ? // On playlist page - sessionRecordingsListLogic({ playlistShortId: props.playlistShortId }) - : // In any other context with a list of recordings (recent recordings) - sessionRecordingsListLogic.findMounted({ updateSearchParams: true }) - - if (listLogic) { - listLogic.actions.loadAllRecordings() + if (props.playlistLogic) { + props.playlistLogic.actions.loadAllRecordings() // Reset selected recording to first one in the list - listLogic.actions.setSelectedRecordingId(null) + props.playlistLogic.actions.setSelectedRecordingId(null) } else if (router.values.location.pathname.includes('/replay')) { // On a page that displays a single recording `replay/:id` that doesn't contain a list router.actions.push(urls.replay()) @@ -1055,7 +1050,7 @@ export const sessionRecordingPlayerLogic = kea( }), ]) -export const getCurrentPlayerTime = (logicProps: SessionRecordingLogicProps): number => { +export const getCurrentPlayerTime = (logicProps: SessionRecordingPlayerLogicProps): number => { // NOTE: We pull this value at call time as otherwise it would trigger re-renders if pulled from the hook const playerTime = sessionRecordingPlayerLogic.findMounted(logicProps)?.values.currentPlayerTime || 0 return Math.floor(playerTime / 1000) diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx index 5132c0ddb5e9d..caf1cc1d99485 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingPreview.tsx @@ -6,20 +6,19 @@ import { IconAutocapture, IconKeyboard, IconPinFilled, IconSchedule } from 'lib/ import { Tooltip } from 'lib/lemon-ui/Tooltip' import { TZLabel } from 'lib/components/TZLabel' import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { RecordingDebugInfo } from '../debug/RecordingDebugInfo' import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' import { urls } from 'scenes/urls' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' import { useValues } from 'kea' import { asDisplay } from 'scenes/persons/person-utils' +import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' export interface SessionRecordingPreviewProps { recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean onPropertyClick?: (property: string, value?: string) => void isActive?: boolean onClick?: () => void + pinned?: boolean } function RecordingDuration({ @@ -56,18 +55,19 @@ function RecordingDuration({ function ActivityIndicators({ recording, - recordingProperties, - recordingPropertiesLoading, onPropertyClick, iconClassnames, }: { recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean onPropertyClick?: (property: string, value?: string) => void iconClassnames: string }): JSX.Element { - const iconPropertyKeys = ['$browser', '$device_type', '$os', '$geoip_country_code'] + const { recordingPropertiesById, recordingPropertiesLoading } = useValues(sessionRecordingsListPropertiesLogic) + + const recordingProperties = recordingPropertiesById[recording.id] + const loading = !recordingProperties && recordingPropertiesLoading + + const iconPropertyKeys = ['$geoip_country_code', '$browser', '$device_type', '$os'] const iconProperties = recordingProperties && Object.keys(recordingProperties).length > 0 ? recordingProperties @@ -75,7 +75,7 @@ function ActivityIndicators({ const propertyIcons = (
- {!recordingPropertiesLoading ? ( + {!loading ? ( iconPropertyKeys.map((property) => { let value = iconProperties?.[property] if (property === '$device_type') { @@ -90,13 +90,18 @@ function ActivityIndicators({ return ( { + if (e.altKey) { + e.stopPropagation() + onPropertyClick?.(property, value) + } + }} className={iconClassnames} property={property} value={value} tooltipTitle={() => (
- Click to filter for + Alt + Click to filter for
{tooltipValue ?? 'N/A'}
@@ -146,18 +151,18 @@ function FirstURL(props: { startUrl: string | undefined }): JSX.Element { ) } -function PinnedIndicator(props: { pinnedCount: number | undefined }): JSX.Element | null { - return (props.pinnedCount ?? 0) > 0 ? ( - +function PinnedIndicator(): JSX.Element | null { + return ( + - ) : null + ) } function ViewedIndicator(props: { viewed: boolean }): JSX.Element | null { return !props.viewed ? ( -
+
) : null } @@ -175,34 +180,22 @@ export function SessionRecordingPreview({ isActive, onClick, onPropertyClick, - recordingProperties, - recordingPropertiesLoading, + pinned, }: SessionRecordingPreviewProps): JSX.Element { const { durationTypeToShow } = useValues(playerSettingsLogic) - const iconClassnames = clsx( - 'SessionRecordingPreview__property-icon text-base text-muted-alt', - !isActive && 'opacity-75' - ) + const iconClassnames = clsx('SessionRecordingPreview__property-icon text-base text-muted-alt') return (
onClick?.()} > -
- -
-
{asDisplay(recording.person)}
@@ -219,17 +212,22 @@ export function SessionRecordingPreview({ - +
- +
+ + {pinned ? : null} +
) diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx deleted file mode 100644 index 5c63c5ceb9b72..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsList.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import { LemonButton } from '@posthog/lemon-ui' -import clsx from 'clsx' -import { IconUnfoldLess, IconUnfoldMore, IconInfo } from 'lib/lemon-ui/icons' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { range } from 'lib/utils' -import React, { Fragment, useEffect, useRef } from 'react' -import { SessionRecordingType } from '~/types' -import { - SessionRecordingPlaylistItem, - SessionRecordingPlaylistItemProps, - SessionRecordingPlaylistItemSkeleton, -} from './SessionRecordingsPlaylistItem' -import { useActions, useValues } from 'kea' -import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' -import { LemonTableLoader } from 'lib/lemon-ui/LemonTable/LemonTableLoader' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' - -const SCROLL_TRIGGER_OFFSET = 100 - -export type SessionRecordingsListProps = { - listKey: string - title: React.ReactNode - titleRight?: React.ReactNode - titleActions?: React.ReactNode - info?: React.ReactNode - recordings?: SessionRecordingType[] - onRecordingClick: (recording: SessionRecordingType) => void - onPropertyClick: SessionRecordingPlaylistItemProps['onPropertyClick'] - activeRecordingId?: SessionRecordingType['id'] - loading?: boolean - loadingSkeletonCount?: number - collapsed?: boolean - onCollapse?: (collapsed: boolean) => void - empty?: React.ReactNode - className?: string - footer?: React.ReactNode - subheader?: React.ReactNode - onScrollToStart?: () => void - onScrollToEnd?: () => void - draggableHref?: string -} - -export function SessionRecordingsList({ - listKey, - titleRight, - titleActions, - recordings, - collapsed, - onCollapse, - title, - loading, - loadingSkeletonCount = 1, - info, - empty, - onRecordingClick, - onPropertyClick, - activeRecordingId, - className, - footer, - subheader, - onScrollToStart, - onScrollToEnd, - draggableHref, -}: SessionRecordingsListProps): JSX.Element { - const { reportRecordingListVisibilityToggled } = useActions(eventUsageLogic) - const lastScrollPositionRef = useRef(0) - const contentRef = useRef(null) - const { recordingPropertiesById, recordingPropertiesLoading } = useValues(sessionRecordingsListPropertiesLogic) - - const titleContent = ( - - {title} - {info ? ( - - - - ) : null} - - ) - - const setCollapsedWrapper = (val: boolean): void => { - onCollapse?.(val) - reportRecordingListVisibilityToggled(listKey, !val) - } - - const handleScroll = - onScrollToEnd || onScrollToStart - ? (e: React.UIEvent): void => { - // If we are scrolling down then check if we are at the bottom of the list - if (e.currentTarget.scrollTop > lastScrollPositionRef.current) { - const scrollPosition = e.currentTarget.scrollTop + e.currentTarget.clientHeight - if (e.currentTarget.scrollHeight - scrollPosition < SCROLL_TRIGGER_OFFSET) { - onScrollToEnd?.() - } - } - - // Same again but if scrolling to the top - if (e.currentTarget.scrollTop < lastScrollPositionRef.current) { - if (e.currentTarget.scrollTop < SCROLL_TRIGGER_OFFSET) { - onScrollToStart?.() - } - } - - lastScrollPositionRef.current = e.currentTarget.scrollTop - } - : undefined - - useEffect(() => { - if (subheader && contentRef.current) { - contentRef.current.scrollTop = 0 - } - }, [!!subheader]) - - return ( -
- -
- {onCollapse ? ( - : } - size="small" - onClick={() => setCollapsedWrapper(!collapsed)} - > - {titleContent} - - ) : ( - - {titleContent} - {titleRight} - - )} - {titleActions} - -
-
- {!collapsed ? ( -
- {subheader} - {recordings?.length ? ( -
    - {recordings.map((rec, i) => ( - - {i > 0 &&
    } - onRecordingClick(rec)} - onPropertyClick={onPropertyClick} - isActive={activeRecordingId === rec.id} - /> - - ))} - - {footer} -
- ) : loading ? ( - <> - {range(loadingSkeletonCount).map((i) => ( - - ))} - - ) : ( -
{empty || info}
- )} -
- ) : null} -
- ) -} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss index cbc6ead8d13b7..afdbf12c51d5c 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss @@ -3,22 +3,31 @@ .SessionRecordingsPlaylist { display: flex; - flex-direction: column-reverse; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; - gap: 1rem; overflow: hidden; - padding-bottom: 1rem; + border: 1px solid var(--border); + border-radius: var(--radius); + height: 100%; - .SessionRecordingsPlaylist__left-column { + .SessionRecordingsPlaylist__list { flex-shrink: 0; - width: 100%; display: flex; flex-direction: column; + max-width: 350px; + min-width: 300px; + width: 25%; + overflow: hidden; + height: 100%; } - .SessionRecordingsPlaylist__right-column { + + .SessionRecordingsPlaylist__player { + flex: 1; + height: 100%; overflow: hidden; width: 100%; - height: 30rem; .SessionRecordingsPlaylist__loading { display: flex; @@ -28,37 +37,41 @@ } } - &--wide { - flex-direction: row; - justify-content: flex-start; - // NOTE: Somewhat random way to offset the various headers and tabs above the playlist - height: calc(100vh - 14rem); - min-height: 41rem; - - .SessionRecordingsPlaylist__left-column { - max-width: 350px; - min-width: 300px; - width: 25%; - overflow: hidden; - height: 100%; - } + &--embedded { + border: none; + } - .SessionRecordingsPlaylist__right-column { + &--wide { + .SessionRecordingsPlaylist__player { flex: 1; height: 100%; } } } -.SessionRecordingsPlaylist__lists { - display: flex; - flex-direction: column; - flex: 1; - overflow: hidden; - gap: 0.5rem; +.SessionRecordingPlaylistHeightWrapper { + // NOTE: Somewhat random way to offset the various headers and tabs above the playlist + height: calc(100vh - 15rem); + min-height: 41rem; } .SessionRecordingPreview { + display: flex; + padding: 0.5rem 0 0.5rem 0.5rem; + cursor: pointer; + position: relative; + overflow: hidden; + border-left: 6px solid transparent; + transition: background-color 200ms ease, border 200ms ease; + + &--active { + border-left-color: var(--primary); + } + + &:hover { + background-color: var(--primary-highlight); + } + .SessionRecordingPreview__property-icon:hover { transition: opacity 200ms; opacity: 1; diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx index 00508be3ab649..ec3aa4b9a723c 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.tsx @@ -1,19 +1,18 @@ -import React, { useState } from 'react' -import { useActions, useValues } from 'kea' -import { RecordingFilters, SessionRecordingType, ReplayTabs, ProductKey } from '~/types' +import React, { useEffect, useRef } from 'react' +import { BindLogic, useActions, useValues } from 'kea' +import { SessionRecordingType, ReplayTabs } from '~/types' import { DEFAULT_RECORDING_FILTERS, defaultPageviewPropertyEntityFilter, RECORDINGS_LIMIT, - SessionRecordingListLogicProps, - sessionRecordingsListLogic, -} from './sessionRecordingsListLogic' + SessionRecordingPlaylistLogicProps, + sessionRecordingsPlaylistLogic, +} from './sessionRecordingsPlaylistLogic' import './SessionRecordingsPlaylist.scss' import { SessionRecordingPlayer } from '../player/SessionRecordingPlayer' import { EmptyMessage } from 'lib/components/EmptyMessage/EmptyMessage' -import { LemonButton, LemonDivider, Link } from '@posthog/lemon-ui' +import { LemonButton, Link } from '@posthog/lemon-ui' import { IconFilter, IconSettings, IconWithCount } from 'lib/lemon-ui/icons' -import { SessionRecordingsList } from './SessionRecordingsList' import clsx from 'clsx' import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' import { Spinner } from 'lib/lemon-ui/Spinner' @@ -21,16 +20,16 @@ import { Tooltip } from 'lib/lemon-ui/Tooltip' import { SessionRecordingsFilters } from '../filters/SessionRecordingsFilters' import { urls } from 'scenes/urls' import { LemonBanner } from 'lib/lemon-ui/LemonBanner' -import { ProductIntroduction } from 'lib/components/ProductIntroduction/ProductIntroduction' -import { openSessionRecordingSettingsDialog } from '../settings/SessionRecordingSettings' -import { teamLogic } from 'scenes/teamLogic' -import { router } from 'kea-router' -import { userLogic } from 'scenes/userLogic' -import { FEATURE_FLAGS } from 'lib/constants' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo' import { SessionRecordingsPlaylistSettings } from './SessionRecordingsPlaylistSettings' import { SessionRecordingsPlaylistTroubleshooting } from './SessionRecordingsPlaylistTroubleshooting' +import { useNotebookNode } from 'scenes/notebooks/Nodes/notebookNodeLogic' +import { LemonTableLoader } from 'lib/lemon-ui/LemonTable/LemonTableLoader' +import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' +import { range } from 'd3' +import { SessionRecordingPreview, SessionRecordingPreviewSkeleton } from './SessionRecordingPreview' + +const SCROLL_TRIGGER_OFFSET = 100 const CounterBadge = ({ children }: { children: React.ReactNode }): JSX.Element => ( {children} @@ -57,49 +56,23 @@ function UnusableEventsWarning(props: { unusableEventsInFilter: string[] }): JSX ) } -export type SessionRecordingsPlaylistProps = SessionRecordingListLogicProps & { - playlistShortId?: string - personUUID?: string - filters?: RecordingFilters - updateSearchParams?: boolean - onFiltersChange?: (filters: RecordingFilters) => void - autoPlay?: boolean - mode?: 'standard' | 'notebook' -} - -export function RecordingsLists({ - playlistShortId, - personUUID, - filters: defaultFilters, - updateSearchParams, - ...props -}: SessionRecordingsPlaylistProps): JSX.Element { - const logicProps: SessionRecordingListLogicProps = { - ...props, - playlistShortId, - personUUID, - filters: defaultFilters, - updateSearchParams, - } - const logic = sessionRecordingsListLogic(logicProps) - +function RecordingsLists(): JSX.Element { const { filters, hasNext, - visibleRecordings, + pinnedRecordings, + otherRecordings, sessionRecordingsResponseLoading, - activeSessionRecording, + activeSessionRecordingId, showFilters, showSettings, - pinnedRecordingsResponse, - pinnedRecordingsResponseLoading, totalFiltersCount, sessionRecordingsAPIErrored, - pinnedRecordingsAPIErrored, unusableEventsInFilter, showAdvancedFilters, hasAdvancedFilters, - } = useValues(logic) + logicProps, + } = useValues(sessionRecordingsPlaylistLogic) const { setSelectedRecordingId, setFilters, @@ -108,8 +81,7 @@ export function RecordingsLists({ setShowSettings, resetFilters, setShowAdvancedFilters, - } = useActions(logic) - const [collapsed, setCollapsed] = useState({ pinned: false, other: false }) + } = useActions(sessionRecordingsPlaylistLogic) const onRecordingClick = (recording: SessionRecordingType): void => { setSelectedRecordingId(recording.id) @@ -119,131 +91,172 @@ export function RecordingsLists({ setFilters(defaultPageviewPropertyEntityFilter(filters, property, value)) } - return ( - <> -
- {/* Pinned recordings */} - {playlistShortId ? ( - {pinnedRecordingsResponse.results.length} - ) : null - } - onRecordingClick={onRecordingClick} - onPropertyClick={onPropertyClick} - collapsed={collapsed.pinned} - onCollapse={() => setCollapsed({ ...collapsed, pinned: !collapsed.pinned })} - recordings={pinnedRecordingsResponse?.results} - loading={pinnedRecordingsResponseLoading} - info={ - <> - You can pin recordings to a playlist to easily keep track of relevant recordings for the - task at hand. Pinned recordings are always shown, regardless of filters. - - } - activeRecordingId={activeSessionRecording?.id} - empty={ - pinnedRecordingsAPIErrored ? ( - Error while trying to load pinned recordings. - ) : unusableEventsInFilter.length ? ( - - ) : undefined - } - /> - ) : null} + const lastScrollPositionRef = useRef(0) + const contentRef = useRef(null) - {/* Other recordings */} - - {visibleRecordings.length ? ( - - Showing {visibleRecordings.length} results. -
- Scrolling to the bottom or the top of the list will load older or newer - recordings respectively. - - } - > - - {Math.min(999, visibleRecordings.length)}+ - -
+ const handleScroll = (e: React.UIEvent): void => { + // If we are scrolling down then check if we are at the bottom of the list + if (e.currentTarget.scrollTop > lastScrollPositionRef.current) { + const scrollPosition = e.currentTarget.scrollTop + e.currentTarget.clientHeight + if (e.currentTarget.scrollHeight - scrollPosition < SCROLL_TRIGGER_OFFSET) { + maybeLoadSessionRecordings('older') + } + } + + // Same again but if scrolling to the top + if (e.currentTarget.scrollTop < lastScrollPositionRef.current) { + if (e.currentTarget.scrollTop < SCROLL_TRIGGER_OFFSET) { + maybeLoadSessionRecordings('newer') + } + } + + lastScrollPositionRef.current = e.currentTarget.scrollTop + } + + useEffect(() => { + if (contentRef.current) { + contentRef.current.scrollTop = 0 + } + }, [showFilters, showSettings]) + + const notebookNode = useNotebookNode() + + return ( +
+ { + +
+ + {!notebookNode ? ( + + Recordings + ) : null} - - } - titleActions={ - <> - - - + + Showing {otherRecordings.length + pinnedRecordings.length} results. +
+ Scrolling to the bottom or the top of the list will load older or newer + recordings respectively. + } - onClick={() => setShowFilters(!showFilters)} > - Filter -
- } - onClick={() => setShowSettings(!showSettings)} - /> - - } - subheader={ - showFilters ? ( -
- resetFilters() : undefined} - hasAdvancedFilters={hasAdvancedFilters} - showAdvancedFilters={showAdvancedFilters} - setShowAdvancedFilters={setShowAdvancedFilters} + + + {Math.min(999, otherRecordings.length + pinnedRecordings.length)}+ + + + + + + + + } + onClick={() => { + if (notebookNode) { + notebookNode.actions.toggleEditing() + } else { + setShowFilters(!showFilters) + } + }} + > + Filter + + } + onClick={() => setShowSettings(!showSettings)} + /> + +
+ + } + +
+ {!notebookNode && showFilters ? ( +
+ resetFilters() : undefined} + hasAdvancedFilters={hasAdvancedFilters} + showAdvancedFilters={showAdvancedFilters} + setShowAdvancedFilters={setShowAdvancedFilters} + /> +
+ ) : showSettings ? ( + + ) : null} + + {pinnedRecordings.length || otherRecordings.length ? ( +
    + {pinnedRecordings.map((rec) => ( +
    + onRecordingClick(rec)} + onPropertyClick={onPropertyClick} + isActive={activeSessionRecordingId === rec.id} + pinned={true} />
    - ) : showSettings ? ( - - ) : null - } - onRecordingClick={onRecordingClick} - onPropertyClick={onPropertyClick} - collapsed={collapsed.other} - onCollapse={ - playlistShortId ? () => setCollapsed({ ...collapsed, other: !collapsed.other }) : undefined - } - recordings={visibleRecordings} - loading={sessionRecordingsResponseLoading} - loadingSkeletonCount={RECORDINGS_LIMIT} - empty={ - sessionRecordingsAPIErrored ? ( + ))} + + {pinnedRecordings.length && otherRecordings.length ? ( +
    + Other recordings +
    + ) : null} + + {otherRecordings.map((rec) => ( +
    + onRecordingClick(rec)} + onPropertyClick={onPropertyClick} + isActive={activeSessionRecordingId === rec.id} + pinned={false} + /> +
    + ))} + +
    + {sessionRecordingsResponseLoading ? ( + <> + Loading older recordings + + ) : hasNext ? ( + maybeLoadSessionRecordings('older')}> + Load more + + ) : ( + 'No more results' + )} +
    +
+ ) : sessionRecordingsResponseLoading ? ( + <> + {range(RECORDINGS_LIMIT).map((i) => ( + + ))} + + ) : ( +
+ {sessionRecordingsAPIErrored ? ( Error while trying to load recordings. ) : unusableEventsInFilter.length ? ( @@ -268,133 +281,77 @@ export function RecordingsLists({ )}
- ) - } - activeRecordingId={activeSessionRecording?.id} - onScrollToEnd={() => maybeLoadSessionRecordings('older')} - onScrollToStart={() => maybeLoadSessionRecordings('newer')} - footer={ - <> - -
- {sessionRecordingsResponseLoading ? ( - <> - Loading older recordings - - ) : hasNext ? ( - maybeLoadSessionRecordings('older')}> - Load more - - ) : ( - 'No more results' - )} -
- - } - draggableHref={urls.replay(ReplayTabs.Recent, filters)} - /> + )} +
+ )}
- +
) } -export function SessionRecordingsPlaylist(props: SessionRecordingsPlaylistProps): JSX.Element { - const { playlistShortId } = props - - const logicProps: SessionRecordingListLogicProps = { +export function SessionRecordingsPlaylist(props: SessionRecordingPlaylistLogicProps): JSX.Element { + const logicProps: SessionRecordingPlaylistLogicProps = { ...props, autoPlay: props.autoPlay ?? true, } - const logic = sessionRecordingsListLogic(logicProps) - const { - activeSessionRecording, - nextSessionRecording, - shouldShowEmptyState, - sessionRecordingsResponseLoading, - matchingEventsMatchType, - } = useValues(logic) - const { currentTeam } = useValues(teamLogic) - const recordingsDisabled = currentTeam && !currentTeam?.session_recording_opt_in - const { user } = useValues(userLogic) - const { featureFlags } = useValues(featureFlagLogic) - const shouldShowProductIntroduction = - !sessionRecordingsResponseLoading && - !user?.has_seen_product_intro_for?.[ProductKey.SESSION_REPLAY] && - !!featureFlags[FEATURE_FLAGS.SHOW_PRODUCT_INTRO_EXISTING_PRODUCTS] + const logic = sessionRecordingsPlaylistLogic(logicProps) + const { activeSessionRecording, activeSessionRecordingId, matchingEventsMatchType, pinnedRecordings } = + useValues(logic) const { ref: playlistRef, size } = useResizeBreakpoints({ 0: 'small', 750: 'medium', }) + const notebookNode = useNotebookNode() + return ( <> - {(shouldShowProductIntroduction || shouldShowEmptyState) && ( - } - onClick={() => openSessionRecordingSettingsDialog()} - > - Enable recordings - - ) : ( - router.actions.push(urls.projectSettings() + '#snippet')} - > - Get the PostHog snippet - - ) - } - /> - )} -
-
- -
-
- {activeSessionRecording?.id ? ( - - ) : ( -
- +
+
+ +
+
+ {activeSessionRecordingId ? ( + x.id === activeSessionRecordingId)} + setPinned={ + props.onPinnedChange + ? (pinned) => { + if (!activeSessionRecording?.id) { + return + } + props.onPinnedChange?.(activeSessionRecording, pinned) + } + : undefined + } /> -
- )} + ) : ( +
+ +
+ )} +
-
+ ) } diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx deleted file mode 100644 index 3a2a662c2419b..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistItem.tsx +++ /dev/null @@ -1,245 +0,0 @@ -import { DurationType, SessionRecordingType } from '~/types' -import { colonDelimitedDuration } from 'lib/utils' -import clsx from 'clsx' -import { PropertyIcon } from 'lib/components/PropertyIcon' -import { IconAutocapture, IconKeyboard, IconPinFilled, IconSchedule } from 'lib/lemon-ui/icons' -import { Tooltip } from 'lib/lemon-ui/Tooltip' -import { TZLabel } from 'lib/components/TZLabel' -import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' -import { RecordingDebugInfo } from '../debug/RecordingDebugInfo' -import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' -import { urls } from 'scenes/urls' -import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' -import { useValues } from 'kea' -import { asDisplay } from 'scenes/persons/person-utils' - -export interface SessionRecordingPlaylistItemProps { - recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean - onPropertyClick: (property: string, value?: string) => void - isActive: boolean - onClick: () => void -} - -function RecordingDuration({ - iconClassNames, - recordingDuration, -}: { - iconClassNames: string - recordingDuration: number | undefined -}): JSX.Element { - if (recordingDuration === undefined) { - return
-
- } - - const formattedDuration = colonDelimitedDuration(recordingDuration) - const [hours, minutes, seconds] = formattedDuration.split(':') - - return ( -
- - - {hours}: - - {minutes}: - - {seconds} - -
- ) -} - -function ActivityIndicators({ - recording, - recordingProperties, - recordingPropertiesLoading, - onPropertyClick, - iconClassnames, -}: { - recording: SessionRecordingType - recordingProperties?: Record // Loaded and rendered later - recordingPropertiesLoading: boolean - onPropertyClick: (property: string, value?: string) => void - iconClassnames: string -}): JSX.Element { - const iconPropertyKeys = ['$browser', '$device_type', '$os', '$geoip_country_code'] - const iconProperties = - recordingProperties && Object.keys(recordingProperties).length > 0 - ? recordingProperties - : recording.person?.properties || {} - - const propertyIcons = ( -
- {!recordingPropertiesLoading ? ( - iconPropertyKeys.map((property) => { - let value = iconProperties?.[property] - if (property === '$device_type') { - value = iconProperties?.['$device_type'] || iconProperties?.['$initial_device_type'] - } - - let tooltipValue = value - if (property === '$geoip_country_code') { - tooltipValue = `${iconProperties?.['$geoip_country_name']} (${value})` - } - - return ( - ( -
- Click to filter for -
- {tooltipValue ?? 'N/A'} -
- )} - /> - ) - }) - ) : ( - - )} -
- ) - - return ( -
- {propertyIcons} - - - - {recording.click_count} - - - - - {recording.keypress_count} - -
- ) -} - -function FirstURL(props: { startUrl: string | undefined }): JSX.Element { - const firstPath = props.startUrl?.replace(/https?:\/\//g, '').split(/[?|#]/)[0] - return ( -
- - - {firstPath} - - -
- ) -} - -function PinnedIndicator(props: { pinnedCount: number | undefined }): JSX.Element | null { - return (props.pinnedCount ?? 0) > 0 ? ( - - - - ) : null -} - -function ViewedIndicator(props: { viewed: boolean }): JSX.Element | null { - return !props.viewed ? ( - -
- - ) : null -} - -function durationToShow(recording: SessionRecordingType, durationType: DurationType | undefined): number | undefined { - return { - duration: recording.recording_duration, - active_seconds: recording.active_seconds, - inactive_seconds: recording.inactive_seconds, - }[durationType || 'duration'] -} - -export function SessionRecordingPlaylistItem({ - recording, - isActive, - onClick, - onPropertyClick, - recordingProperties, - recordingPropertiesLoading, -}: SessionRecordingPlaylistItemProps): JSX.Element { - const { durationTypeToShow } = useValues(playerSettingsLogic) - - const iconClassnames = clsx( - 'SessionRecordingsPlaylist__list-item__property-icon text-base text-muted-alt', - !isActive && 'opacity-75' - ) - - return ( - -
  • onClick()} - > -
    - -
    -
    -
    -
    - -
    - {asDisplay(recording.person)} -
    -
    -
    - - -
    - -
    - - -
    - - -
    - - -
  • -
    - ) -} - -export function SessionRecordingPlaylistItemSkeleton(): JSX.Element { - return ( -
    - - -
    - ) -} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx index 1a2842f934ff3..447fe87662123 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx @@ -5,24 +5,28 @@ import { LemonSkeleton } from 'lib/lemon-ui/LemonSkeleton' import { SceneExport } from 'scenes/sceneTypes' import { EditableField } from 'lib/components/EditableField/EditableField' import { PageHeader } from 'lib/components/PageHeader' -import { sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' +import { sessionRecordingsPlaylistSceneLogic } from './sessionRecordingsPlaylistSceneLogic' import { NotFound } from 'lib/components/NotFound' import { UserActivityIndicator } from 'lib/components/UserActivityIndicator/UserActivityIndicator' import { More } from 'lib/lemon-ui/LemonButton/More' -import { SessionRecordingsPlaylist } from './SessionRecordingsPlaylist' import { playerSettingsLogic } from 'scenes/session-recordings/player/playerSettingsLogic' +import { SessionRecordingsPlaylist } from './SessionRecordingsPlaylist' export const scene: SceneExport = { component: SessionRecordingsPlaylistScene, - logic: sessionRecordingsPlaylistLogic, + logic: sessionRecordingsPlaylistSceneLogic, paramsToProps: ({ params: { id } }) => { return { shortId: id as string } }, } export function SessionRecordingsPlaylistScene(): JSX.Element { - const { playlist, playlistLoading, hasChanges, derivedName } = useValues(sessionRecordingsPlaylistLogic) - const { setFilters, updatePlaylist, duplicatePlaylist, deletePlaylist } = useActions(sessionRecordingsPlaylistLogic) + const { playlist, playlistLoading, pinnedRecordings, hasChanges, derivedName } = useValues( + sessionRecordingsPlaylistSceneLogic + ) + const { setFilters, updatePlaylist, duplicatePlaylist, deletePlaylist, onPinnedChange } = useActions( + sessionRecordingsPlaylistSceneLogic + ) const { showFilters } = useValues(playerSettingsLogic) const { setShowFilters } = useActions(playerSettingsLogic) @@ -58,7 +62,7 @@ export function SessionRecordingsPlaylistScene(): JSX.Element { return ( // Margin bottom hacks the fact that our wrapping container has an annoyingly large padding -
    +
    {playlist.short_id ? ( - +
    + +
    ) : null}
    ) diff --git a/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts b/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts index 0862e5e8a799a..9767d4b41809a 100644 --- a/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts +++ b/frontend/src/scenes/session-recordings/playlist/playlistUtils.ts @@ -6,7 +6,7 @@ import { convertPropertyGroupToProperties, deleteWithUndo, genericOperatorMap } import { getKeyMapping } from 'lib/taxonomy' import api from 'lib/api' import { lemonToast } from 'lib/lemon-ui/lemonToast' -import { DEFAULT_RECORDING_FILTERS } from 'scenes/session-recordings/playlist/sessionRecordingsListLogic' +import { DEFAULT_RECORDING_FILTERS } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' import { router } from 'kea-router' import { urls } from 'scenes/urls' import { openBillingPopupModal } from 'scenes/billing/BillingPopup' diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts deleted file mode 100644 index 4e49627a5612b..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.test.ts +++ /dev/null @@ -1,518 +0,0 @@ -import { - sessionRecordingsListLogic, - RECORDINGS_LIMIT, - DEFAULT_RECORDING_FILTERS, - defaultRecordingDurationFilter, -} from './sessionRecordingsListLogic' -import { expectLogic } from 'kea-test-utils' -import { initKeaTests } from '~/test/init' -import { router } from 'kea-router' -import { PropertyFilterType, PropertyOperator, RecordingFilters } from '~/types' -import { useMocks } from '~/mocks/jest' -import { sessionRecordingDataLogic } from '../player/sessionRecordingDataLogic' - -describe('sessionRecordingsListLogic', () => { - let logic: ReturnType - 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: [], - }, - }, - }) - initKeaTests() - logic = sessionRecordingsListLogic({ - 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, () => { - // load is called on mount - // logic.actions.loadSessionRecordings() - logic.actions.setSelectedRecordingId('abc') - }) - .toDispatchActionsInAnyOrder(['loadSessionRecordings', 'loadSessionRecordingsSuccess']) - .toMatchValues({ shouldShowEmptyState: true }) - }) - - it('is false after API call error', async () => { - await expectLogic(logic, () => { - // load is called on mount - // logic.actions.loadSessionRecordings() - 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'], - }, - ] - } - return [ - 200, - { - results: listOfSessionRecordings, - }, - ] - }, - '/api/projects/:team/session_recording_playlists/:playlist_id/recordings': () => { - return [ - 200, - { - results: ['Pinned recordings'], - }, - ] - }, - }, - }) - initKeaTests() - }) - - describe('global logic', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'tests', - playlistShortId: 'playlist-test', - updateSearchParams: true, - }) - logic.mount() - }) - - describe('core assumptions', () => { - it('loads recent recordings and pinned recordings after mounting', async () => { - await expectLogic(logic) - .toDispatchActionsInAnyOrder(['loadSessionRecordingsSuccess', 'loadPinnedRecordingsSuccess']) - .toMatchValues({ - sessionRecordings: listOfSessionRecordings, - pinnedRecordingsResponse: { - results: ['Pinned recordings'], - }, - }) - }) - }) - - describe('should show empty state', () => { - it('starts out false', async () => { - await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) - }) - }) - - describe('activeSessionRecording', () => { - it('starts as null', () => { - expectLogic(logic).toMatchValues({ activeSessionRecording: undefined }) - }) - it('is set by setSessionRecordingId', async () => { - expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc')) - .toDispatchActions(['loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'abc', - activeSessionRecording: listOfSessionRecordings[0], - }) - expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') - }) - - it('is partial if sessionRecordingId not in list', async () => { - 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') - }) - - 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') - - await expectLogic(logic) - .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'loadSessionRecordingsSuccess']) - .toMatchValues({ - selectedRecordingId: 'abc', - activeSessionRecording: listOfSessionRecordings[0], - }) - }) - - it('mounts and loads the recording when a recording is opened', () => { - expectLogic(logic, async () => await logic.actions.setSelectedRecordingId('abcd')) - .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' })) - .toDispatchActions(['loadEntireRecording']) - }) - - 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') - }) - }) - - describe('entityFilters', () => { - it('starts with default values', () => { - expectLogic(logic).toMatchValues({ filters: DEFAULT_RECORDING_FILTERS }) - }) - - 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' }, - ]) - }) - }) - - describe('date range', () => { - it('is set by setFilters and fetches results from server and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - 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') - }) - }) - describe('duration filter', () => { - it('is set by setFilters and fetches results from server and sets the url', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - 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, - }) - }) - }) - - describe('fetch pinned recordings', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'static-tests', - playlistShortId: 'static-playlist-test', - }) - logic.mount() - }) - it('calls list session recordings for static playlists', async () => { - await expectLogic(logic) - .toDispatchActions(['loadPinnedRecordingsSuccess']) - .toMatchValues({ - pinnedRecordingsResponse: { - results: ['Pinned recordings'], - }, - }) - }) - }) - - describe('set recording from hash param', () => { - it('loads the correct recording from the hash params', async () => { - router.actions.push('/replay/recent', {}, { sessionRecordingId: 'abc' }) - - logic = sessionRecordingsListLogic({ - key: 'hash-recording-tests', - updateSearchParams: true, - }) - logic.mount() - - 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, - }, - 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: [ - { - ...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' }], - }) - }) - .toDispatchActions(['setFilters', '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, - }, - }, - }) - - 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', { - filters: { - actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], - }, - }) - - 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: [], - }, - }) - }) - }) - - describe('person specific logic', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - 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 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('total filters count', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - 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('counts console log filters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - console_logs: ['warn', 'error'], - } satisfies Partial) - }).toMatchValues({ totalFiltersCount: 2 }) - }) - }) - - describe('resetting filters', () => { - beforeEach(() => { - logic = sessionRecordingsListLogic({ - key: 'cool_user_99', - personUUID: 'cool_user_99', - updateSearchParams: true, - }) - logic.mount() - }) - - it('resets console log filters', async () => { - await expectLogic(logic, () => { - logic.actions.setFilters({ - console_logs: ['warn', 'error'], - } satisfies Partial) - logic.actions.resetFilters() - }).toMatchValues({ totalFiltersCount: 0 }) - }) - }) - }) -}) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts deleted file mode 100644 index a97acaf6d5520..0000000000000 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsListLogic.ts +++ /dev/null @@ -1,661 +0,0 @@ -import { actions, afterMount, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' -import api from 'lib/api' -import { objectClean, objectsEqual, toParams } from 'lib/utils' -import { - AnyPropertyFilter, - PropertyFilterType, - PropertyOperator, - RecordingDurationFilter, - RecordingFilters, - SessionRecordingId, - SessionRecordingsResponse, - SessionRecordingType, -} from '~/types' -import type { sessionRecordingsListLogicType } from './sessionRecordingsListLogicType' -import { actionToUrl, router, urlToAction } from 'kea-router' -import { eventUsageLogic } from 'lib/utils/eventUsageLogic' -import equal from 'fast-deep-equal' -import { loaders } from 'kea-loaders' -import { featureFlagLogic } from 'lib/logic/featureFlagLogic' -import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' -import { playerSettingsLogic } from '../player/playerSettingsLogic' -import posthog from 'posthog-js' - -export type PersonUUID = string - -interface Params { - filters?: RecordingFilters - sessionRecordingId?: SessionRecordingId -} - -interface NoEventsToMatch { - matchType: 'none' -} - -interface EventNamesMatching { - matchType: 'name' - eventNames: string[] -} - -interface EventUUIDsMatching { - matchType: 'uuid' - eventUUIDs: string[] -} - -interface BackendEventsMatching { - matchType: 'backend' - filters: RecordingFilters -} - -export type MatchingEventsMatchType = NoEventsToMatch | EventNamesMatching | EventUUIDsMatching | BackendEventsMatching - -export const RECORDINGS_LIMIT = 20 -export const PINNED_RECORDINGS_LIMIT = 100 // NOTE: This is high but avoids the need for pagination for now... - -export const defaultRecordingDurationFilter: RecordingDurationFilter = { - type: PropertyFilterType.Recording, - key: 'duration', - value: 1, - operator: PropertyOperator.GreaterThan, -} - -export const DEFAULT_RECORDING_FILTERS: RecordingFilters = { - session_recording_duration: defaultRecordingDurationFilter, - properties: [], - events: [], - actions: [], - date_from: '-7d', - date_to: null, - console_logs: [], -} - -const DEFAULT_PERSON_RECORDING_FILTERS: RecordingFilters = { - ...DEFAULT_RECORDING_FILTERS, - date_from: '-21d', -} - -export const getDefaultFilters = (personUUID?: PersonUUID): RecordingFilters => { - return personUUID ? DEFAULT_PERSON_RECORDING_FILTERS : DEFAULT_RECORDING_FILTERS -} - -export const addedAdvancedFilters = ( - filters: RecordingFilters | undefined, - defaultFilters: RecordingFilters -): boolean => { - if (!filters) { - return false - } - - const hasActions = filters.actions ? filters.actions.length > 0 : false - const hasChangedDateFrom = filters.date_from != defaultFilters.date_from - const hasChangedDateTo = filters.date_to != defaultFilters.date_to - const hasConsoleLogsFilters = filters.console_logs ? filters.console_logs.length > 0 : false - const hasChangedDuration = !equal(filters.session_recording_duration, defaultFilters.session_recording_duration) - const eventsFilters = filters.events || [] - const hasAdvancedEvents = eventsFilters.length > 1 || (!!eventsFilters[0] && eventsFilters[0].name != '$pageview') - - return ( - hasActions || - hasAdvancedEvents || - hasChangedDuration || - hasChangedDateFrom || - hasChangedDateTo || - hasConsoleLogsFilters - ) -} - -export const defaultPageviewPropertyEntityFilter = ( - filters: RecordingFilters, - property: string, - value?: string -): Partial => { - const existingPageview = filters.events?.find(({ name }) => name === '$pageview') - const eventEntityFilters = filters.events ?? [] - const propToAdd = value - ? { - key: property, - value: [value], - operator: PropertyOperator.Exact, - type: 'event', - } - : { - key: property, - value: PropertyOperator.IsNotSet, - operator: PropertyOperator.IsNotSet, - type: 'event', - } - - // If pageview exists, add property to the first pageview event - if (existingPageview) { - return { - events: eventEntityFilters.map((eventFilter) => - eventFilter.order === existingPageview.order - ? { - ...eventFilter, - properties: [ - ...(eventFilter.properties?.filter(({ key }: AnyPropertyFilter) => key !== property) ?? - []), - propToAdd, - ], - } - : eventFilter - ), - } - } else { - return { - events: [ - ...eventEntityFilters, - { - id: '$pageview', - name: '$pageview', - type: 'events', - order: eventEntityFilters.length, - properties: [propToAdd], - }, - ], - } - } -} - -export interface SessionRecordingListLogicProps { - logicKey?: string - playlistShortId?: string - personUUID?: PersonUUID - filters?: RecordingFilters - updateSearchParams?: boolean - autoPlay?: boolean - onFiltersChange?: (filters: RecordingFilters) => void -} - -export const sessionRecordingsListLogic = kea([ - path((key) => ['scenes', 'session-recordings', 'playlist', 'sessionRecordingsListLogic', key]), - props({} as SessionRecordingListLogicProps), - key( - (props: SessionRecordingListLogicProps) => - `${props.logicKey}-${props.playlistShortId}-${props.personUUID}-${ - props.updateSearchParams ? '-with-search' : '' - }` - ), - connect({ - actions: [ - eventUsageLogic, - ['reportRecordingsListFetched', 'reportRecordingsListFilterAdded'], - sessionRecordingsListPropertiesLogic, - ['maybeLoadPropertiesForSessions'], - ], - values: [ - featureFlagLogic, - ['featureFlags'], - playerSettingsLogic, - ['autoplayDirection', 'hideViewedRecordings'], - ], - }), - actions({ - setFilters: (filters: Partial) => ({ filters }), - setShowFilters: (showFilters: boolean) => ({ showFilters }), - setShowAdvancedFilters: (showAdvancedFilters: boolean) => ({ showAdvancedFilters }), - setShowSettings: (showSettings: boolean) => ({ showSettings }), - resetFilters: true, - setSelectedRecordingId: (id: SessionRecordingType['id'] | null) => ({ - id, - }), - loadAllRecordings: true, - loadPinnedRecordings: true, - loadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), - maybeLoadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), - loadNext: true, - loadPrev: true, - }), - propsChanged(({ actions, props }, oldProps) => { - if (!objectsEqual(props.filters, oldProps.filters)) { - props.filters ? actions.setFilters(props.filters) : actions.resetFilters() - } - }), - - loaders(({ props, values, actions }) => ({ - eventsHaveSessionId: [ - {} as Record, - { - loadEventsHaveSessionId: async () => { - const events = values.filters.events - if (events === undefined || events.length === 0) { - return {} - } - - return await api.propertyDefinitions.seenTogether({ - eventNames: events.map((event) => event.name), - propertyDefinitionName: '$session_id', - }) - }, - }, - ], - sessionRecordingsResponse: [ - { - results: [], - has_next: false, - } as SessionRecordingsResponse, - { - loadSessionRecordings: async ({ direction }, breakpoint) => { - const paramsDict = { - ...values.filters, - person_uuid: props.personUUID ?? '', - limit: RECORDINGS_LIMIT, - } - - if (direction === 'older') { - paramsDict['date_to'] = - values.sessionRecordings[values.sessionRecordings.length - 1]?.start_time - } - - if (direction === 'newer') { - paramsDict['date_from'] = values.sessionRecordings[0]?.start_time - } - - const params = toParams(paramsDict) - - await breakpoint(100) // Debounce for lots of quick filter changes - - const startTime = performance.now() - const response = await api.recordings.list(params) - const loadTimeMs = performance.now() - startTime - - actions.reportRecordingsListFetched(loadTimeMs) - - breakpoint() - - return { - has_next: - direction === 'newer' - ? values.sessionRecordingsResponse?.has_next ?? true - : response.has_next, - results: response.results, - } - }, - }, - ], - pinnedRecordingsResponse: [ - null as SessionRecordingsResponse | null, - { - loadPinnedRecordings: async (_, breakpoint) => { - if (!props.playlistShortId) { - return null - } - - const paramsDict = { - limit: PINNED_RECORDINGS_LIMIT, - } - - const params = toParams(paramsDict) - await breakpoint(100) - const response = await api.recordings.listPlaylistRecordings(props.playlistShortId, params) - breakpoint() - return response - }, - }, - ], - })), - reducers(({ props }) => ({ - unusableEventsInFilter: [ - [] as string[], - { - loadEventsHaveSessionIdSuccess: (_, { eventsHaveSessionId }) => { - return Object.entries(eventsHaveSessionId) - .filter(([, hasSessionId]) => !hasSessionId) - .map(([eventName]) => eventName) - }, - }, - ], - customFilters: [ - (props.filters ?? null) as RecordingFilters | null, - { - setFilters: (state, { filters }) => ({ - ...state, - ...filters, - }), - resetFilters: () => null, - }, - ], - showFilters: [ - true, - { - persist: true, - }, - { - setShowFilters: (_, { showFilters }) => showFilters, - setShowSettings: () => false, - }, - ], - showSettings: [ - false, - { - persist: true, - }, - { - setShowSettings: (_, { showSettings }) => showSettings, - setShowFilters: () => false, - }, - ], - showAdvancedFilters: [ - addedAdvancedFilters(props.filters, getDefaultFilters(props.personUUID)), - { - setFilters: (showingAdvancedFilters, { filters }) => - addedAdvancedFilters(filters, getDefaultFilters(props.personUUID)) ? true : showingAdvancedFilters, - setShowAdvancedFilters: (_, { showAdvancedFilters }) => showAdvancedFilters, - }, - ], - sessionRecordings: [ - [] as SessionRecordingType[], - { - loadSessionRecordings: (state, { direction }) => { - // Reset if we are not paginating - return direction ? state : [] - }, - - loadSessionRecordingsSuccess: (state, { sessionRecordingsResponse }) => { - const mergedResults: SessionRecordingType[] = [...state] - - sessionRecordingsResponse.results.forEach((recording) => { - if (!state.find((r) => r.id === recording.id)) { - mergedResults.push(recording) - } - }) - - mergedResults.sort((a, b) => (a.start_time > b.start_time ? -1 : 1)) - - return mergedResults - }, - setSelectedRecordingId: (state, { id }) => - state.map((s) => { - if (s.id === id) { - return { - ...s, - viewed: true, - } - } else { - return { ...s } - } - }), - }, - ], - selectedRecordingId: [ - null as SessionRecordingType['id'] | null, - { - setSelectedRecordingId: (_, { id }) => id ?? null, - }, - ], - sessionRecordingsAPIErrored: [ - false, - { - loadSessionRecordingsFailure: () => true, - loadSessionRecordingSuccess: () => false, - setFilters: () => false, - loadNext: () => false, - loadPrev: () => false, - }, - ], - pinnedRecordingsAPIErrored: [ - false, - { - loadPinnedRecordingsFailure: () => true, - loadPinnedRecordingsSuccess: () => false, - setFilters: () => false, - loadNext: () => false, - loadPrev: () => false, - }, - ], - })), - listeners(({ props, actions, values }) => ({ - loadAllRecordings: () => { - actions.loadSessionRecordings() - actions.loadPinnedRecordings() - }, - setFilters: ({ 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, - }) - - actions.loadEventsHaveSessionId() - }, - - resetFilters: () => { - actions.loadSessionRecordings() - props.onFiltersChange?.(values.filters) - }, - - maybeLoadSessionRecordings: ({ direction }) => { - if (direction === 'older' && !values.hasNext) { - return // Nothing more to load - } - if (values.sessionRecordingsResponseLoading) { - return // We don't want to load if we are currently loading - } - actions.loadSessionRecordings(direction) - }, - - loadSessionRecordingsSuccess: () => { - actions.maybeLoadPropertiesForSessions(values.sessionRecordings) - }, - - setSelectedRecordingId: () => { - // If we are at the end of the list then try to load more - const recordingIndex = values.sessionRecordings.findIndex((s) => s.id === values.selectedRecordingId) - if (recordingIndex === values.sessionRecordings.length - 1) { - actions.maybeLoadSessionRecordings('older') - } - }, - })), - selectors({ - shouldShowEmptyState: [ - (s) => [ - s.sessionRecordings, - s.customFilters, - s.sessionRecordingsResponseLoading, - s.sessionRecordingsAPIErrored, - s.pinnedRecordingsAPIErrored, - (_, props) => props.personUUID, - ], - ( - sessionRecordings, - customFilters, - sessionRecordingsResponseLoading, - sessionRecordingsAPIErrored, - pinnedRecordingsAPIErrored, - personUUID - ): boolean => { - return ( - !sessionRecordingsAPIErrored && - !pinnedRecordingsAPIErrored && - !sessionRecordingsResponseLoading && - sessionRecordings.length === 0 && - !customFilters && - !personUUID - ) - }, - ], - - filters: [ - (s) => [s.customFilters, (_, props) => props.personUUID], - (customFilters, personUUID): RecordingFilters => { - const defaultFilters = getDefaultFilters(personUUID) - return { - ...defaultFilters, - ...customFilters, - } - }, - ], - - matchingEventsMatchType: [ - (s) => [s.filters], - (filters: RecordingFilters | undefined): MatchingEventsMatchType => { - if (!filters) { - return { matchType: 'none' } - } - - const hasActions = !!filters.actions?.length - const hasEvents = !!filters.events?.length - const simpleEventsFilters = (filters.events || []) - .filter((e) => !e.properties || !e.properties.length) - .map((e) => e.name.toString()) - const hasSimpleEventsFilters = !!simpleEventsFilters.length - - if (hasActions) { - return { matchType: 'backend', filters } - } else { - if (!hasEvents) { - return { matchType: 'none' } - } - - if (hasEvents && hasSimpleEventsFilters && simpleEventsFilters.length === filters.events?.length) { - return { - matchType: 'name', - eventNames: simpleEventsFilters, - } - } else { - return { - matchType: 'backend', - filters, - } - } - } - }, - ], - activeSessionRecording: [ - (s) => [s.selectedRecordingId, s.sessionRecordings, (_, props) => props.autoPlay], - (selectedRecordingId, sessionRecordings, autoPlay): Partial | undefined => { - return selectedRecordingId - ? sessionRecordings.find((sessionRecording) => sessionRecording.id === selectedRecordingId) || { - id: selectedRecordingId, - } - : autoPlay - ? sessionRecordings[0] - : undefined - }, - ], - nextSessionRecording: [ - (s) => [s.activeSessionRecording, s.sessionRecordings, s.autoplayDirection], - ( - activeSessionRecording, - sessionRecordings, - autoplayDirection - ): Partial | undefined => { - if (!activeSessionRecording || !autoplayDirection) { - return - } - const activeSessionRecordingIndex = sessionRecordings.findIndex( - (x) => x.id === activeSessionRecording.id - ) - return autoplayDirection === 'older' - ? sessionRecordings[activeSessionRecordingIndex + 1] - : sessionRecordings[activeSessionRecordingIndex - 1] - }, - ], - hasNext: [ - (s) => [s.sessionRecordingsResponse], - (sessionRecordingsResponse) => sessionRecordingsResponse.has_next, - ], - totalFiltersCount: [ - (s) => [s.filters, (_, props) => props.personUUID], - (filters, personUUID) => { - const defaultFilters = getDefaultFilters(personUUID) - - return ( - (filters?.actions?.length || 0) + - (filters?.events?.length || 0) + - (filters?.properties?.length || 0) + - (equal(filters.session_recording_duration, defaultFilters.session_recording_duration) ? 0 : 1) + - (filters.date_from === defaultFilters.date_from && filters.date_to === defaultFilters.date_to - ? 0 - : 1) + - (filters.console_logs?.length || 0) - ) - }, - ], - hasAdvancedFilters: [ - (s) => [s.filters, (_, props) => props.personUUID], - (filters, personUUID) => { - const defaultFilters = getDefaultFilters(personUUID) - return addedAdvancedFilters(filters, defaultFilters) - }, - ], - visibleRecordings: [ - (s) => [s.sessionRecordings, s.hideViewedRecordings], - (sessionRecordings, hideViewedRecordings) => { - return hideViewedRecordings ? sessionRecordings.filter((r) => !r.viewed) : sessionRecordings - }, - ], - }), - - actionToUrl(({ props, values }) => { - if (!props.updateSearchParams) { - return {} - } - const buildURL = ( - replace: boolean - ): [ - string, - Params, - Record, - { - replace: boolean - } - ] => { - const params: Params = objectClean({ - filters: values.customFilters ?? 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), - resetFilters: () => buildURL(true), - } - }), - - urlToAction(({ actions, values, props }) => { - const urlToAction = (_: any, params: Params, hashParams: 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 - if (nulledSessionRecordingId !== values.selectedRecordingId) { - actions.setSelectedRecordingId(nulledSessionRecordingId) - } - - if (params.filters) { - if (!equal(params.filters, values.customFilters)) { - actions.setFilters(params.filters) - } - } - } - return { - '*': urlToAction, - } - }), - - // NOTE: It is important this comes after urlToAction, as it will override the default behavior - afterMount(({ actions }) => { - actions.loadSessionRecordings() - actions.loadPinnedRecordings() - }), -]) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts index 5aa7701de99c7..bbe331b1f9c08 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.test.ts @@ -1,81 +1,491 @@ +import { + sessionRecordingsPlaylistLogic, + RECORDINGS_LIMIT, + DEFAULT_RECORDING_FILTERS, + defaultRecordingDurationFilter, +} from './sessionRecordingsPlaylistLogic' import { expectLogic } from 'kea-test-utils' import { initKeaTests } from '~/test/init' +import { router } from 'kea-router' +import { PropertyFilterType, PropertyOperator, RecordingFilters } from '~/types' import { useMocks } from '~/mocks/jest' -import { sessionRecordingsPlaylistLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic' +import { sessionRecordingDataLogic } from '../player/sessionRecordingDataLogic' describe('sessionRecordingsPlaylistLogic', () => { let logic: ReturnType - const mockPlaylist = { - id: 'abc', - short_id: 'short_abc', - name: 'Test Playlist', - filters: { - events: [], - date_from: '2022-10-18', - session_recording_duration: { - key: 'duration', - type: 'recording', - value: 60, - operator: 'gt', - }, - }, - } - - beforeEach(() => { - useMocks({ - get: { - '/api/projects/:team/session_recording_playlists/:id': mockPlaylist, - }, - patch: { - '/api/projects/:team/session_recording_playlists/:id': () => { - return [ - 200, - { - updated_playlist: 'blah', - }, - ] + 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: [], + }, }, - }, + }) + initKeaTests() + logic = sessionRecordingsPlaylistLogic({ + key: 'tests', + updateSearchParams: true, + }) + logic.mount() }) - initKeaTests() - }) - beforeEach(() => { - logic = sessionRecordingsPlaylistLogic({ shortId: mockPlaylist.short_id }) - 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 }) + }) - describe('core assumptions', () => { - it('loads playlist after mounting', async () => { - await expectLogic(logic).toDispatchActions(['getPlaylistSuccess']) - expect(logic.values.playlist).toEqual(mockPlaylist) + it('is false after API call error', async () => { + await expectLogic(logic, () => { + logic.actions.loadSessionRecordingsFailure('abc') + }).toMatchValues({ shouldShowEmptyState: false }) + }) }) }) - describe('update playlist', () => { - it('set new filter then update playlist', () => { - const newFilter = { - events: [ - { - id: '$autocapture', - type: 'events', - order: 0, - name: '$autocapture', + 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'], + }, + ] + } + 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, + }) + logic.mount() + }) + + describe('core assumptions', () => { + it('loads recent recordings after mounting', async () => { + await expectLogic(logic) + .toDispatchActionsInAnyOrder(['loadSessionRecordingsSuccess']) + .toMatchValues({ + sessionRecordings: listOfSessionRecordings, + }) + }) + }) + + describe('should show empty state', () => { + it('starts out false', async () => { + await expectLogic(logic).toMatchValues({ shouldShowEmptyState: false }) + }) + }) + + describe('activeSessionRecording', () => { + it('starts as null', () => { + expectLogic(logic).toMatchValues({ activeSessionRecording: undefined }) + }) + it('is set by setSessionRecordingId', async () => { + expectLogic(logic, () => logic.actions.setSelectedRecordingId('abc')) + .toDispatchActions(['loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'abc', + activeSessionRecording: listOfSessionRecordings[0], + }) + expect(router.values.searchParams).toHaveProperty('sessionRecordingId', 'abc') + }) + + it('is partial if sessionRecordingId not in list', async () => { + 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') + }) + + 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') + + await expectLogic(logic) + .toDispatchActionsInAnyOrder(['setSelectedRecordingId', 'loadSessionRecordingsSuccess']) + .toMatchValues({ + selectedRecordingId: 'abc', + activeSessionRecording: listOfSessionRecordings[0], + }) + }) + + it('mounts and loads the recording when a recording is opened', () => { + expectLogic(logic, async () => await logic.actions.setSelectedRecordingId('abcd')) + .toMount(sessionRecordingDataLogic({ sessionRecordingId: 'abcd' })) + .toDispatchActions(['loadEntireRecording']) + }) + + 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') + }) + }) + + describe('entityFilters', () => { + it('starts with default values', () => { + expectLogic(logic).toMatchValues({ filters: DEFAULT_RECORDING_FILTERS }) + }) + + 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' }, + ]) + }) + }) + + describe('date range', () => { + it('is set by setFilters and fetches results from server and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + 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') + }) + }) + describe('duration filter', () => { + it('is set by setFilters and fetches results from server and sets the url', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + 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, + }) + }) + }) + + 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() + + 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, + }, + 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: [ + { + ...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' }], + }) + }) + .toDispatchActions(['setFilters', '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, + }, }, - ], - } - expectLogic(logic, async () => { - await logic.actions.setFilters(newFilter) - await logic.actions.updatePlaylist({}) - }) - .toDispatchActions(['setFilters']) - .toMatchValues({ filters: expect.objectContaining(newFilter), hasChanges: true }) - .toDispatchActions(['saveChanges', 'updatePlaylist', 'updatePlaylistSuccess']) - .toMatchValues({ - playlist: { - updated_playlist: 'blah', + }) + + 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', { + filters: { + actions: [{ id: '1', type: 'actions', order: 0, name: 'View Recording' }], }, }) + + 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: [], + }, + }) + }) + }) + + describe('person specific logic', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, + }) + 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 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('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('counts console log filters', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + console_logs: ['warn', 'error'], + } satisfies Partial) + }).toMatchValues({ totalFiltersCount: 2 }) + }) + }) + + describe('resetting filters', () => { + beforeEach(() => { + logic = sessionRecordingsPlaylistLogic({ + key: 'cool_user_99', + personUUID: 'cool_user_99', + updateSearchParams: true, + }) + logic.mount() + }) + + it('resets console log filters', async () => { + await expectLogic(logic, () => { + logic.actions.setFilters({ + console_logs: ['warn', 'error'], + } satisfies Partial) + logic.actions.resetFilters() + }).toMatchValues({ totalFiltersCount: 0 }) + }) }) }) }) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index ddb860ec707eb..9c8f68c321b29 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -1,128 +1,680 @@ -import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' -import { Breadcrumb, RecordingFilters, SessionRecordingPlaylistType, ReplayTabs } from '~/types' -import type { sessionRecordingsPlaylistLogicType } from './sessionRecordingsPlaylistLogicType' -import { urls } from 'scenes/urls' -import equal from 'fast-deep-equal' -import { beforeUnload, router } from 'kea-router' -import { cohortsModel } from '~/models/cohortsModel' +import { actions, afterMount, connect, kea, key, listeners, path, props, propsChanged, reducers, selectors } from 'kea' +import api from 'lib/api' +import { objectClean, objectsEqual } from 'lib/utils' import { - deletePlaylist, - duplicatePlaylist, - getPlaylist, - summarizePlaylistFilters, - updatePlaylist, -} from 'scenes/session-recordings/playlist/playlistUtils' + AnyPropertyFilter, + PropertyFilterType, + PropertyOperator, + RecordingDurationFilter, + RecordingFilters, + SessionRecordingId, + SessionRecordingsResponse, + SessionRecordingType, +} from '~/types' +import { actionToUrl, router, urlToAction } from 'kea-router' +import { eventUsageLogic } from 'lib/utils/eventUsageLogic' +import equal from 'fast-deep-equal' import { loaders } from 'kea-loaders' +import { featureFlagLogic } from 'lib/logic/featureFlagLogic' +import { sessionRecordingsListPropertiesLogic } from './sessionRecordingsListPropertiesLogic' +import { playerSettingsLogic } from '../player/playerSettingsLogic' +import posthog from 'posthog-js' + +import type { sessionRecordingsPlaylistLogicType } from './sessionRecordingsPlaylistLogicType' + +export type PersonUUID = string + +interface Params { + filters?: RecordingFilters + sessionRecordingId?: SessionRecordingId +} + +interface NoEventsToMatch { + matchType: 'none' +} + +interface EventNamesMatching { + matchType: 'name' + eventNames: string[] +} + +interface EventUUIDsMatching { + matchType: 'uuid' + eventUUIDs: string[] +} + +interface BackendEventsMatching { + matchType: 'backend' + filters: RecordingFilters +} + +export type MatchingEventsMatchType = NoEventsToMatch | EventNamesMatching | EventUUIDsMatching | BackendEventsMatching + +export const RECORDINGS_LIMIT = 20 +export const PINNED_RECORDINGS_LIMIT = 100 // NOTE: This is high but avoids the need for pagination for now... + +export const defaultRecordingDurationFilter: RecordingDurationFilter = { + type: PropertyFilterType.Recording, + key: 'duration', + value: 1, + operator: PropertyOperator.GreaterThan, +} + +export const DEFAULT_RECORDING_FILTERS: RecordingFilters = { + session_recording_duration: defaultRecordingDurationFilter, + properties: [], + events: [], + actions: [], + date_from: '-7d', + date_to: null, + console_logs: [], +} -export interface SessionRecordingsPlaylistLogicProps { - shortId: string +const DEFAULT_PERSON_RECORDING_FILTERS: RecordingFilters = { + ...DEFAULT_RECORDING_FILTERS, + date_from: '-21d', +} + +export const getDefaultFilters = (personUUID?: PersonUUID): RecordingFilters => { + return personUUID ? DEFAULT_PERSON_RECORDING_FILTERS : DEFAULT_RECORDING_FILTERS +} + +export const addedAdvancedFilters = ( + filters: RecordingFilters | undefined, + defaultFilters: RecordingFilters +): boolean => { + if (!filters) { + return false + } + + const hasActions = filters.actions ? filters.actions.length > 0 : false + const hasChangedDateFrom = filters.date_from != defaultFilters.date_from + const hasChangedDateTo = filters.date_to != defaultFilters.date_to + const hasConsoleLogsFilters = filters.console_logs ? filters.console_logs.length > 0 : false + const hasChangedDuration = !equal(filters.session_recording_duration, defaultFilters.session_recording_duration) + const eventsFilters = filters.events || [] + const hasAdvancedEvents = eventsFilters.length > 1 || (!!eventsFilters[0] && eventsFilters[0].name != '$pageview') + + return ( + hasActions || + hasAdvancedEvents || + hasChangedDuration || + hasChangedDateFrom || + hasChangedDateTo || + hasConsoleLogsFilters + ) +} + +export const defaultPageviewPropertyEntityFilter = ( + filters: RecordingFilters, + property: string, + value?: string +): Partial => { + const existingPageview = filters.events?.find(({ name }) => name === '$pageview') + const eventEntityFilters = filters.events ?? [] + const propToAdd = value + ? { + key: property, + value: [value], + operator: PropertyOperator.Exact, + type: 'event', + } + : { + key: property, + value: PropertyOperator.IsNotSet, + operator: PropertyOperator.IsNotSet, + type: 'event', + } + + // If pageview exists, add property to the first pageview event + if (existingPageview) { + return { + events: eventEntityFilters.map((eventFilter) => + eventFilter.order === existingPageview.order + ? { + ...eventFilter, + properties: [ + ...(eventFilter.properties?.filter(({ key }: AnyPropertyFilter) => key !== property) ?? + []), + propToAdd, + ], + } + : eventFilter + ), + } + } else { + return { + events: [ + ...eventEntityFilters, + { + id: '$pageview', + name: '$pageview', + type: 'events', + order: eventEntityFilters.length, + properties: [propToAdd], + }, + ], + } + } +} + +export interface SessionRecordingPlaylistLogicProps { + logicKey?: string + personUUID?: PersonUUID + updateSearchParams?: boolean + autoPlay?: boolean + filters?: RecordingFilters + onFiltersChange?: (filters: RecordingFilters) => void + pinnedRecordings?: (SessionRecordingType | string)[] + onPinnedChange?: (recording: SessionRecordingType, pinned: boolean) => void } export const sessionRecordingsPlaylistLogic = kea([ path((key) => ['scenes', 'session-recordings', 'playlist', 'sessionRecordingsPlaylistLogic', key]), - props({} as SessionRecordingsPlaylistLogicProps), - key((props) => props.shortId), + props({} as SessionRecordingPlaylistLogicProps), + key( + (props: SessionRecordingPlaylistLogicProps) => + `${props.logicKey}-${props.personUUID}-${props.updateSearchParams ? '-with-search' : ''}` + ), connect({ - values: [cohortsModel, ['cohortsById']], + actions: [ + eventUsageLogic, + ['reportRecordingsListFetched', 'reportRecordingsListFilterAdded'], + sessionRecordingsListPropertiesLogic, + ['maybeLoadPropertiesForSessions'], + ], + values: [ + featureFlagLogic, + ['featureFlags'], + playerSettingsLogic, + ['autoplayDirection', 'hideViewedRecordings'], + ], }), actions({ - updatePlaylist: (properties?: Partial, silent = false) => ({ - properties, - silent, + setFilters: (filters: Partial) => ({ filters }), + setShowFilters: (showFilters: boolean) => ({ showFilters }), + setShowAdvancedFilters: (showAdvancedFilters: boolean) => ({ showAdvancedFilters }), + setShowSettings: (showSettings: boolean) => ({ showSettings }), + resetFilters: true, + setSelectedRecordingId: (id: SessionRecordingType['id'] | null) => ({ + id, }), - setFilters: (filters: RecordingFilters | null) => ({ filters }), + loadAllRecordings: true, + loadPinnedRecordings: true, + loadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), + maybeLoadSessionRecordings: (direction?: 'newer' | 'older') => ({ direction }), + loadNext: true, + loadPrev: true, }), - loaders(({ values, props }) => ({ - playlist: [ - null as SessionRecordingPlaylistType | null, + propsChanged(({ actions, props }, oldProps) => { + if (!objectsEqual(props.filters, oldProps.filters)) { + props.filters ? actions.setFilters(props.filters) : actions.resetFilters() + } + + // If the defined list changes, we need to call the loader to either load the new items or change the list + if (props.pinnedRecordings !== oldProps.pinnedRecordings) { + actions.loadPinnedRecordings() + } + }), + + loaders(({ props, values, actions }) => ({ + eventsHaveSessionId: [ + {} as Record, { - getPlaylist: async () => { - return getPlaylist(props.shortId) - }, - updatePlaylist: async ({ properties, silent }) => { - if (!values.playlist?.short_id) { - return values.playlist + loadEventsHaveSessionId: async () => { + const events = values.filters.events + if (events === undefined || events.length === 0) { + return {} } - return updatePlaylist( - values.playlist?.short_id, - properties ?? { filters: values.filters || undefined }, - silent - ) + + return await api.propertyDefinitions.seenTogether({ + eventNames: events.map((event) => event.name), + propertyDefinitionName: '$session_id', + }) }, - duplicatePlaylist: async () => { - return duplicatePlaylist(values.playlist ?? {}, true) + }, + ], + sessionRecordingsResponse: [ + { + results: [], + has_next: false, + } as SessionRecordingsResponse, + { + loadSessionRecordings: async ({ direction }, breakpoint) => { + const params = { + ...values.filters, + person_uuid: props.personUUID ?? '', + limit: RECORDINGS_LIMIT, + } + + if (direction === 'older') { + params['date_to'] = values.sessionRecordings[values.sessionRecordings.length - 1]?.start_time + } + + if (direction === 'newer') { + params['date_from'] = values.sessionRecordings[0]?.start_time + } + + await breakpoint(100) // Debounce for lots of quick filter changes + + const startTime = performance.now() + const response = await api.recordings.list(params) + const loadTimeMs = performance.now() - startTime + + actions.reportRecordingsListFetched(loadTimeMs) + + breakpoint() + + return { + has_next: + direction === 'newer' + ? values.sessionRecordingsResponse?.has_next ?? true + : response.has_next, + results: response.results, + } }, - deletePlaylist: async () => { - if (values.playlist) { - return deletePlaylist(values.playlist, () => { - router.actions.replace(urls.replay(ReplayTabs.Playlists)) + }, + ], + + pinnedRecordings: [ + [] as SessionRecordingType[], + { + loadPinnedRecordings: async (_, breakpoint) => { + await breakpoint(100) + + // props.pinnedRecordings can be strings or objects. + // If objects we can simply use them, if strings we need to fetch them + + const pinnedRecordings = props.pinnedRecordings ?? [] + + let recordings = pinnedRecordings.filter((x) => typeof x !== 'string') as SessionRecordingType[] + const recordingIds = pinnedRecordings.filter((x) => typeof x === 'string') as string[] + + if (recordingIds.length) { + const fetchedRecordings = await api.recordings.list({ + session_ids: recordingIds, }) + + recordings = [...recordings, ...fetchedRecordings.results] } - return null + // TODO: Check for pinnedRecordings being IDs and fetch them, returnig the merged list + + return recordings }, }, ], })), - reducers(() => ({ - filters: [ - null as RecordingFilters | null, + reducers(({ props }) => ({ + unusableEventsInFilter: [ + [] as string[], { - getPlaylistSuccess: (_, { playlist }) => playlist?.filters || null, - updatePlaylistSuccess: (_, { playlist }) => playlist?.filters || null, - setFilters: (_, { filters }) => filters, + loadEventsHaveSessionIdSuccess: (_, { eventsHaveSessionId }) => { + return Object.entries(eventsHaveSessionId) + .filter(([, hasSessionId]) => !hasSessionId) + .map(([eventName]) => eventName) + }, + }, + ], + customFilters: [ + (props.filters ?? null) as RecordingFilters | null, + { + setFilters: (state, { filters }) => ({ + ...state, + ...filters, + }), + resetFilters: () => null, + }, + ], + showFilters: [ + true, + { + persist: true, + }, + { + setShowFilters: (_, { showFilters }) => showFilters, + setShowSettings: () => false, + }, + ], + showSettings: [ + false, + { + persist: true, + }, + { + setShowSettings: (_, { showSettings }) => showSettings, + setShowFilters: () => false, + }, + ], + showAdvancedFilters: [ + addedAdvancedFilters(props.filters, getDefaultFilters(props.personUUID)), + { + setFilters: (showingAdvancedFilters, { filters }) => + addedAdvancedFilters(filters, getDefaultFilters(props.personUUID)) ? true : showingAdvancedFilters, + setShowAdvancedFilters: (_, { showAdvancedFilters }) => showAdvancedFilters, + }, + ], + sessionRecordings: [ + [] as SessionRecordingType[], + { + loadSessionRecordings: (state, { direction }) => { + // Reset if we are not paginating + return direction ? state : [] + }, + + loadSessionRecordingsSuccess: (state, { sessionRecordingsResponse }) => { + const mergedResults: SessionRecordingType[] = [...state] + + sessionRecordingsResponse.results.forEach((recording) => { + if (!state.find((r) => r.id === recording.id)) { + mergedResults.push(recording) + } + }) + + mergedResults.sort((a, b) => (a.start_time > b.start_time ? -1 : 1)) + + return mergedResults + }, + setSelectedRecordingId: (state, { id }) => + state.map((s) => { + if (s.id === id) { + return { + ...s, + viewed: true, + } + } else { + return { ...s } + } + }), + }, + ], + selectedRecordingId: [ + null as SessionRecordingType['id'] | null, + { + setSelectedRecordingId: (_, { id }) => id ?? null, + }, + ], + sessionRecordingsAPIErrored: [ + false, + { + loadSessionRecordingsFailure: () => true, + loadSessionRecordingSuccess: () => false, + setFilters: () => false, + loadNext: () => false, + loadPrev: () => false, }, ], })), + listeners(({ props, actions, values }) => ({ + loadAllRecordings: () => { + actions.loadSessionRecordings() + actions.loadPinnedRecordings() + }, + setFilters: ({ 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, + }) + + actions.loadEventsHaveSessionId() + }, - listeners(({ actions, values }) => ({ - getPlaylistSuccess: () => { - if (values.playlist?.derived_name !== values.derivedName) { - // This keeps the derived name up to date if the playlist changes - actions.updatePlaylist({ derived_name: values.derivedName }, true) + resetFilters: () => { + actions.loadSessionRecordings() + props.onFiltersChange?.(values.filters) + }, + + maybeLoadSessionRecordings: ({ direction }) => { + if (direction === 'older' && !values.hasNext) { + return // Nothing more to load + } + if (values.sessionRecordingsResponseLoading) { + return // We don't want to load if we are currently loading } + actions.loadSessionRecordings(direction) }, - })), - beforeUnload(({ values, actions }) => ({ - enabled: (newLocation) => values.hasChanges && newLocation?.pathname !== router.values.location.pathname, - message: 'Leave playlist?\nChanges you made will be discarded.', - onConfirm: () => { - actions.setFilters(values.playlist?.filters || null) + loadSessionRecordingsSuccess: () => { + actions.maybeLoadPropertiesForSessions(values.sessionRecordings) }, - })), - selectors(() => ({ - breadcrumbs: [ - (s) => [s.playlist], - (playlist): Breadcrumb[] => [ - { - name: 'Recordings', - path: urls.replay(), - }, - { - name: 'Playlists', - path: urls.replay(ReplayTabs.Playlists), - }, - { - name: playlist?.name || playlist?.derived_name || '(Untitled)', - path: urls.replayPlaylist(playlist?.short_id || ''), - }, + setSelectedRecordingId: () => { + // If we are at the end of the list then try to load more + const recordingIndex = values.sessionRecordings.findIndex((s) => s.id === values.selectedRecordingId) + if (recordingIndex === values.sessionRecordings.length - 1) { + actions.maybeLoadSessionRecordings('older') + } + }, + })), + selectors({ + logicProps: [() => [(_, 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) + return { + ...defaultFilters, + ...customFilters, + } + }, + ], + + matchingEventsMatchType: [ + (s) => [s.filters], + (filters: RecordingFilters | undefined): MatchingEventsMatchType => { + if (!filters) { + return { matchType: 'none' } + } + + const hasActions = !!filters.actions?.length + const hasEvents = !!filters.events?.length + const simpleEventsFilters = (filters.events || []) + .filter((e) => !e.properties || !e.properties.length) + .map((e) => e.name.toString()) + const hasSimpleEventsFilters = !!simpleEventsFilters.length + + if (hasActions) { + return { matchType: 'backend', filters } + } else { + if (!hasEvents) { + return { matchType: 'none' } + } + + if (hasEvents && hasSimpleEventsFilters && simpleEventsFilters.length === filters.events?.length) { + return { + matchType: 'name', + eventNames: simpleEventsFilters, + } + } else { + return { + matchType: 'backend', + filters, + } + } + } + }, ], - hasChanges: [ - (s) => [s.playlist, s.filters], - (playlist, filters): boolean => { - return !equal(playlist?.filters, filters) + activeSessionRecordingId: [ + (s) => [s.selectedRecordingId, s.recordings, (_, props) => props.autoPlay], + (selectedRecordingId, recordings, autoPlay): SessionRecordingId | undefined => { + return selectedRecordingId + ? recordings.find((rec) => rec.id === selectedRecordingId)?.id || selectedRecordingId + : autoPlay + ? recordings[0]?.id + : undefined }, ], - derivedName: [ - (s) => [s.filters, s.cohortsById], - (filters, cohortsById) => - summarizePlaylistFilters(filters || {}, cohortsById)?.slice(0, 400) || '(Untitled)', + activeSessionRecording: [ + (s) => [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 => { + if (!activeSessionRecording || !autoplayDirection) { + return + } + const activeSessionRecordingIndex = recordings.findIndex((x) => x.id === activeSessionRecording.id) + return autoplayDirection === 'older' + ? recordings[activeSessionRecordingIndex + 1] + : recordings[activeSessionRecordingIndex - 1] + }, + ], + hasNext: [ + (s) => [s.sessionRecordingsResponse], + (sessionRecordingsResponse) => sessionRecordingsResponse.has_next, + ], + totalFiltersCount: [ + (s) => [s.filters, (_, props) => props.personUUID], + (filters, personUUID) => { + const defaultFilters = getDefaultFilters(personUUID) + + return ( + (filters?.actions?.length || 0) + + (filters?.events?.length || 0) + + (filters?.properties?.length || 0) + + (equal(filters.session_recording_duration, defaultFilters.session_recording_duration) ? 0 : 1) + + (filters.date_from === defaultFilters.date_from && filters.date_to === defaultFilters.date_to + ? 0 + : 1) + + (filters.console_logs?.length || 0) + ) + }, + ], + hasAdvancedFilters: [ + (s) => [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], + ( + sessionRecordings, + hideViewedRecordings, + pinnedRecordings, + selectedRecordingId + ): SessionRecordingType[] => { + return sessionRecordings.filter((rec) => { + if (pinnedRecordings.find((pinned) => pinned.id === rec.id)) { + return false + } + + if (hideViewedRecordings && rec.viewed && rec.id !== selectedRecordingId) { + return false + } + + return true + }) + }, + ], + + recordings: [ + (s) => [s.pinnedRecordings, s.otherRecordings], + (pinnedRecordings, otherRecordings): SessionRecordingType[] => { + return [...pinnedRecordings, ...otherRecordings] + }, + ], + }), + + actionToUrl(({ props, values }) => { + if (!props.updateSearchParams) { + return {} + } + const buildURL = ( + replace: boolean + ): [ + string, + Params, + Record, + { + replace: boolean + } + ] => { + const params: Params = objectClean({ + filters: values.customFilters ?? 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), + resetFilters: () => buildURL(true), + } + }), + + urlToAction(({ actions, values, props }) => { + const urlToAction = (_: any, params: Params, hashParams: 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 + if (nulledSessionRecordingId !== values.selectedRecordingId) { + actions.setSelectedRecordingId(nulledSessionRecordingId) + } + + if (params.filters) { + if (!equal(params.filters, values.customFilters)) { + actions.setFilters(params.filters) + } + } + } + return { + '*': urlToAction, + } + }), + // NOTE: It is important this comes after urlToAction, as it will override the default behavior afterMount(({ actions }) => { - actions.getPlaylist() + actions.loadSessionRecordings() + actions.loadPinnedRecordings() }), ]) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts new file mode 100644 index 0000000000000..4530486fb5ed0 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.test.ts @@ -0,0 +1,81 @@ +import { expectLogic } from 'kea-test-utils' +import { initKeaTests } from '~/test/init' +import { useMocks } from '~/mocks/jest' +import { sessionRecordingsPlaylistSceneLogic } from 'scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic' + +describe('sessionRecordingsPlaylistSceneLogic', () => { + let logic: ReturnType + const mockPlaylist = { + id: 'abc', + short_id: 'short_abc', + name: 'Test Playlist', + filters: { + events: [], + date_from: '2022-10-18', + session_recording_duration: { + key: 'duration', + type: 'recording', + value: 60, + operator: 'gt', + }, + }, + } + + beforeEach(() => { + useMocks({ + get: { + '/api/projects/:team/session_recording_playlists/:id': mockPlaylist, + }, + patch: { + '/api/projects/:team/session_recording_playlists/:id': () => { + return [ + 200, + { + updated_playlist: 'blah', + }, + ] + }, + }, + }) + initKeaTests() + }) + + beforeEach(() => { + logic = sessionRecordingsPlaylistSceneLogic({ shortId: mockPlaylist.short_id }) + logic.mount() + }) + + describe('core assumptions', () => { + it('loads playlist after mounting', async () => { + await expectLogic(logic).toDispatchActions(['getPlaylistSuccess']) + expect(logic.values.playlist).toEqual(mockPlaylist) + }) + }) + + describe('update playlist', () => { + it('set new filter then update playlist', () => { + const newFilter = { + events: [ + { + id: '$autocapture', + type: 'events', + order: 0, + name: '$autocapture', + }, + ], + } + expectLogic(logic, async () => { + await logic.actions.setFilters(newFilter) + await logic.actions.updatePlaylist({}) + }) + .toDispatchActions(['setFilters']) + .toMatchValues({ filters: expect.objectContaining(newFilter), hasChanges: true }) + .toDispatchActions(['saveChanges', 'updatePlaylist', 'updatePlaylistSuccess']) + .toMatchValues({ + playlist: { + updated_playlist: 'blah', + }, + }) + }) + }) +}) diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts new file mode 100644 index 0000000000000..f5e310872f570 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistSceneLogic.ts @@ -0,0 +1,168 @@ +import { actions, afterMount, connect, kea, key, listeners, path, props, reducers, selectors } from 'kea' +import { Breadcrumb, RecordingFilters, SessionRecordingPlaylistType, ReplayTabs, SessionRecordingType } from '~/types' +import { urls } from 'scenes/urls' +import equal from 'fast-deep-equal' +import { beforeUnload, router } from 'kea-router' +import { cohortsModel } from '~/models/cohortsModel' +import { + deletePlaylist, + duplicatePlaylist, + getPlaylist, + summarizePlaylistFilters, + updatePlaylist, +} from 'scenes/session-recordings/playlist/playlistUtils' +import { loaders } from 'kea-loaders' + +import type { sessionRecordingsPlaylistSceneLogicType } from './sessionRecordingsPlaylistSceneLogicType' +import { PINNED_RECORDINGS_LIMIT } from './sessionRecordingsPlaylistLogic' +import api from 'lib/api' +import { addRecordingToPlaylist, removeRecordingFromPlaylist } from '../player/utils/playerUtils' + +export interface SessionRecordingsPlaylistLogicProps { + shortId: string +} + +export const sessionRecordingsPlaylistSceneLogic = kea([ + path((key) => ['scenes', 'session-recordings', 'playlist', 'sessionRecordingsPlaylistSceneLogic', key]), + props({} as SessionRecordingsPlaylistLogicProps), + key((props) => props.shortId), + connect({ + values: [cohortsModel, ['cohortsById']], + }), + actions({ + updatePlaylist: (properties?: Partial, silent = false) => ({ + properties, + silent, + }), + setFilters: (filters: RecordingFilters | null) => ({ filters }), + loadPinnedRecordings: true, + onPinnedChange: (recording: SessionRecordingType, pinned: boolean) => ({ pinned, recording }), + }), + loaders(({ values, props }) => ({ + playlist: [ + null as SessionRecordingPlaylistType | null, + { + getPlaylist: async () => { + return getPlaylist(props.shortId) + }, + updatePlaylist: async ({ properties, silent }) => { + if (!values.playlist?.short_id) { + return values.playlist + } + return updatePlaylist( + values.playlist?.short_id, + properties ?? { filters: values.filters || undefined }, + silent + ) + }, + duplicatePlaylist: async () => { + return duplicatePlaylist(values.playlist ?? {}, true) + }, + deletePlaylist: async () => { + if (values.playlist) { + return deletePlaylist(values.playlist, () => { + router.actions.replace(urls.replay(ReplayTabs.Playlists)) + }) + } + return null + }, + }, + ], + + pinnedRecordings: [ + null as SessionRecordingType[] | null, + { + loadPinnedRecordings: async (_, breakpoint) => { + if (!props.shortId) { + return null + } + + await breakpoint(100) + const response = await api.recordings.listPlaylistRecordings(props.shortId, { + limit: PINNED_RECORDINGS_LIMIT, + }) + breakpoint() + return response.results + }, + + onPinnedChange: async ({ recording, pinned }) => { + let newResults = values.pinnedRecordings ?? [] + + newResults = newResults.filter((r) => r.id !== recording.id) + + if (pinned) { + await addRecordingToPlaylist(props.shortId, recording.id) + newResults.push(recording) + } else { + await removeRecordingFromPlaylist(props.shortId, recording.id) + } + + return newResults + }, + }, + ], + })), + reducers(() => ({ + filters: [ + null as RecordingFilters | null, + { + getPlaylistSuccess: (_, { playlist }) => playlist?.filters || null, + updatePlaylistSuccess: (_, { playlist }) => playlist?.filters || null, + setFilters: (_, { filters }) => filters, + }, + ], + })), + + listeners(({ actions, values }) => ({ + getPlaylistSuccess: () => { + if (values.playlist?.derived_name !== values.derivedName) { + // This keeps the derived name up to date if the playlist changes + actions.updatePlaylist({ derived_name: values.derivedName }, true) + } + }, + })), + + beforeUnload(({ values, actions }) => ({ + enabled: (newLocation) => values.hasChanges && newLocation?.pathname !== router.values.location.pathname, + message: 'Leave playlist?\nChanges you made will be discarded.', + onConfirm: () => { + actions.setFilters(values.playlist?.filters || null) + }, + })), + + selectors(() => ({ + breadcrumbs: [ + (s) => [s.playlist], + (playlist): Breadcrumb[] => [ + { + name: 'Replay', + path: urls.replay(), + }, + { + name: 'Playlists', + path: urls.replay(ReplayTabs.Playlists), + }, + { + name: playlist?.name || playlist?.derived_name || '(Untitled)', + path: urls.replayPlaylist(playlist?.short_id || ''), + }, + ], + ], + hasChanges: [ + (s) => [s.playlist, s.filters], + (playlist, filters): boolean => { + return !equal(playlist?.filters, filters) + }, + ], + derivedName: [ + (s) => [s.filters, s.cohortsById], + (filters, cohortsById) => + summarizePlaylistFilters(filters || {}, cohortsById)?.slice(0, 400) || '(Untitled)', + ], + })), + + afterMount(({ actions }) => { + actions.getPlaylist() + actions.loadPinnedRecordings() + }), +]) diff --git a/frontend/src/styles/utilities.scss b/frontend/src/styles/utilities.scss index 24664fb521a6b..f4b621f9abd75 100644 --- a/frontend/src/styles/utilities.scss +++ b/frontend/src/styles/utilities.scss @@ -244,6 +244,9 @@ .border-4 { border-width: 4px; } +.border-6 { + border-width: 6px; +} .border-8 { border-width: 8px; } @@ -256,6 +259,9 @@ .border-t-4 { border-top-width: 4px; } +.border-t-6 { + border-top-width: 6px; +} .border-t-8 { border-top-width: 8px; } @@ -271,6 +277,9 @@ .border-r-4 { border-right-width: 4px; } +.border-r-6 { + border-right-width: 6px; +} .border-r-8 { border-right-width: 8px; } @@ -286,6 +295,9 @@ .border-b-4 { border-bottom-width: 4px; } +.border-b-6 { + border-bottom-width: 6px; +} .border-b-8 { border-bottom-width: 8px; } @@ -301,6 +313,9 @@ .border-l-4 { border-left-width: 4px; } +.border-l-6 { + border-left-width: 6px; +} .border-l-8 { border-left-width: 8px; } diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 1bbbf70268f26..6061c2392bb00 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -689,7 +689,6 @@ export interface SessionPlayerSnapshotData { } export interface SessionPlayerData { - pinnedCount: number person: PersonType | null segments: RecordingSegment[] bufferedToTime: number | null @@ -1031,13 +1030,11 @@ export interface SessionRecordingType { /** count of all mouse activity in the recording, not just clicks */ mouse_activity_count?: number start_url?: string - /** Count of number of playlists this recording is pinned to. **/ - pinned_count?: number console_log_count?: number console_warn_count?: number console_error_count?: number /** Where this recording information was loaded from */ - storage?: 'object_storage_lts' | 'clickhouse' | 'object_storage' + storage?: 'object_storage_lts' | 'object_storage' } export interface SessionRecordingPropertiesType { diff --git a/package.json b/package.json index 8b87445c5542c..7910b06f33045 100644 --- a/package.json +++ b/package.json @@ -197,6 +197,7 @@ "@testing-library/dom": ">=7.21.4", "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", + "@testing-library/react-hooks": "^8.0.1", "@testing-library/user-event": "^13.5.0", "@types/chartjs-plugin-crosshair": "^1.1.1", "@types/clone": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index df1bf198aa3e0..bafb490675b4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -408,6 +408,9 @@ devDependencies: '@testing-library/react': specifier: ^12.1.2 version: 12.1.5(react-dom@16.14.0)(react@16.14.0) + '@testing-library/react-hooks': + specifier: ^8.0.1 + version: 8.0.1(@types/react@16.14.34)(react-dom@16.14.0)(react@16.14.0) '@testing-library/user-event': specifier: ^13.5.0 version: 13.5.0(@testing-library/dom@8.19.0) @@ -5422,6 +5425,29 @@ packages: redent: 3.0.0 dev: true + /@testing-library/react-hooks@8.0.1(@types/react@16.14.34)(react-dom@16.14.0)(react@16.14.0): + resolution: {integrity: sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g==} + engines: {node: '>=12'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 + react: ^16.9.0 || ^17.0.0 + react-dom: ^16.9.0 || ^17.0.0 + react-test-renderer: ^16.9.0 || ^17.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + react-dom: + optional: true + react-test-renderer: + optional: true + dependencies: + '@babel/runtime': 7.22.10 + '@types/react': 16.14.34 + react: 16.14.0 + react-dom: 16.14.0(react@16.14.0) + react-error-boundary: 3.1.4(react@16.14.0) + dev: true + /@testing-library/react@12.1.5(react-dom@16.14.0)(react@16.14.0): resolution: {integrity: sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==} engines: {node: '>=12'} @@ -16084,6 +16110,16 @@ packages: react-is: 18.1.0 dev: true + /react-error-boundary@3.1.4(react@16.14.0): + resolution: {integrity: sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==} + engines: {node: '>=10', npm: '>=6'} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.22.10 + react: 16.14.0 + dev: true + /react-grid-layout@1.3.4(react-dom@16.14.0)(react@16.14.0): resolution: {integrity: sha512-sB3rNhorW77HUdOjB4JkelZTdJGQKuXLl3gNg+BI8gJkTScspL1myfZzW/EM0dLEn+1eH+xW+wNqk0oIM9o7cw==} peerDependencies: diff --git a/posthog/models/filters/mixins/session_recordings.py b/posthog/models/filters/mixins/session_recordings.py index 1d9b58c73cf83..42366c7957bb0 100644 --- a/posthog/models/filters/mixins/session_recordings.py +++ b/posthog/models/filters/mixins/session_recordings.py @@ -44,12 +44,23 @@ def recording_duration_filter(self) -> Optional[Property]: @cached_property def session_ids(self) -> Optional[List[str]]: + # Can be ['a', 'b'] or "['a', 'b']" or "a,b" session_ids_str = self._data.get(SESSION_RECORDINGS_FILTER_IDS, None) + if session_ids_str is None: return None - recordings_ids = json.loads(session_ids_str) - if isinstance(recordings_ids, list) and all(isinstance(recording_id, str) for recording_id in recordings_ids): + if isinstance(session_ids_str, list): + recordings_ids = session_ids_str + elif isinstance(session_ids_str, str): + if session_ids_str.startswith("["): + recordings_ids = json.loads(session_ids_str) + else: + recordings_ids = session_ids_str.split(",") + + if all(isinstance(recording_id, str) for recording_id in recordings_ids): # Sort for stable queries return sorted(recordings_ids) - return None + + # If the property is at all present, we assume that the user wants to filter by it + return [] diff --git a/posthog/session_recordings/models/session_recording.py b/posthog/session_recordings/models/session_recording.py index b2918ceed3d09..ec259fbaf9143 100644 --- a/posthog/session_recordings/models/session_recording.py +++ b/posthog/session_recordings/models/session_recording.py @@ -1,7 +1,6 @@ from typing import Any, List, Optional, Literal from django.db import models -from django.db.models import Count from django.dispatch import receiver from posthog.celery import ee_persist_single_recording @@ -59,7 +58,6 @@ class Meta: viewed: Optional[bool] = False person: Optional[Person] = None matching_events: Optional[RecordingMatchingEvents] = None - pinned_count: int = 0 # Metadata can be loaded from Clickhouse or S3 _metadata: Optional[RecordingMetadata] = None @@ -148,7 +146,10 @@ def can_load_more_snapshots(self): @property def storage(self): - return "object_storage_lts" if self.object_storage_path else "clickhouse" + if self._state.adding: + return "object_storage" + + return "object_storage_lts" def load_person(self) -> Optional[Person]: if self.person: @@ -195,9 +196,7 @@ def _build_session_blob_path(self, root_prefix: str) -> str: @staticmethod def get_or_build(session_id: str, team: Team) -> "SessionRecording": try: - return SessionRecording.objects.annotate(pinned_count=Count("playlist_items")).get( - session_id=session_id, team=team - ) + return SessionRecording.objects.get(session_id=session_id, team=team) except SessionRecording.DoesNotExist: return SessionRecording(session_id=session_id, team=team) @@ -207,9 +206,7 @@ def get_or_build_from_clickhouse(team: Team, ch_recordings: List[dict]) -> "List recordings_by_id = { recording.session_id: recording - for recording in SessionRecording.objects.filter(session_id__in=session_ids, team=team) - .annotate(pinned_count=Count("playlist_items")) - .all() + for recording in SessionRecording.objects.filter(session_id__in=session_ids, team=team).all() } recordings = [] diff --git a/posthog/session_recordings/queries/test/test_session_replay_events.py b/posthog/session_recordings/queries/test/test_session_replay_events.py index c304233ff98d4..bbdec4ea0cc3e 100644 --- a/posthog/session_recordings/queries/test/test_session_replay_events.py +++ b/posthog/session_recordings/queries/test/test_session_replay_events.py @@ -36,7 +36,6 @@ def setUp(self): ) def test_get_metadata(self) -> None: - metadata = SessionReplayEvents().get_metadata(session_id="1", team=self.team) assert metadata == { "active_seconds": 25.0, diff --git a/posthog/session_recordings/session_recording_api.py b/posthog/session_recordings/session_recording_api.py index cf3505c556752..6ef5595cad560 100644 --- a/posthog/session_recordings/session_recording_api.py +++ b/posthog/session_recordings/session_recording_api.py @@ -2,12 +2,12 @@ import json from typing import Any, List, Type, cast +from django.conf import settings import posthoganalytics -from dateutil import parser import requests from django.contrib.auth.models import AnonymousUser -from django.db.models import Count, Prefetch +from django.db.models import Prefetch from django.http import JsonResponse, HttpResponse from drf_spectacular.utils import extend_schema from loginas.utils import is_impersonated_session @@ -93,7 +93,6 @@ class Meta: "start_url", "person", "storage", - "pinned_count", ] read_only_fields = [ @@ -113,7 +112,6 @@ class Meta: "console_error_count", "start_url", "storage", - "pinned_count", ] @@ -153,6 +151,8 @@ class SessionRecordingViewSet(StructuredViewSetMixin, viewsets.GenericViewSet): permission_classes = [IsAuthenticated, ProjectMembershipNecessaryPermissions, TeamMemberAccessPermission] throttle_classes = [ClickHouseBurstRateThrottle, ClickHouseSustainedRateThrottle] serializer_class = SessionRecordingSerializer + # We don't use this + queryset = SessionRecording.objects.none() sharing_enabled_actions = ["retrieve", "snapshots", "snapshot_file"] @@ -213,13 +213,6 @@ def matching_events(self, request: request.Request, *args: Any, **kwargs: Any) - def retrieve(self, request: request.Request, *args: Any, **kwargs: Any) -> Response: recording = self.get_object() - # Optimisation step if passed to speed up retrieval of CH data - if not recording.start_time: - recording_start_time = ( - parser.parse(request.GET["recording_start_time"]) if request.GET.get("recording_start_time") else None - ) - recording.start_time = recording_start_time - loaded = recording.load_metadata() if not loaded: @@ -246,6 +239,20 @@ def destroy(self, request: request.Request, *args: Any, **kwargs: Any) -> Respon return Response({"success": True}, status=204) + @action(methods=["POST"], detail=True) + def persist(self, request: request.Request, *args: Any, **kwargs: Any) -> Response: + recording = self.get_object() + + if not settings.EE_AVAILABLE: + raise exceptions.ValidationError("LTS persistence is only available in the full version of PostHog") + + # Indicates it is not yet persisted + # "Persistence" is simply saving a record in the DB currently - the actual save to S3 is done on a worker + if recording.storage == "object_storage": + recording.save() + + return Response({"success": True}) + def _snapshots_v2(self, request: request.Request): """ This will eventually replace the snapshots endpoint below. @@ -425,13 +432,6 @@ def snapshots(self, request: request.Request, **kwargs): self._distinct_id_from_request(request), "v1 session recording snapshots viewed", event_properties ) - # Optimisation step if passed to speed up retrieval of CH data - if not recording.start_time: - recording_start_time = ( - parser.parse(request.GET["recording_start_time"]) if request.GET.get("recording_start_time") else None - ) - recording.start_time = recording_start_time - try: recording.load_snapshots(limit, offset) except NotImplementedError as e: @@ -502,6 +502,7 @@ def list_recordings(filter: SessionRecordingsFilter, request: request.Request, c """ all_session_ids = filter.session_ids + recordings: List[SessionRecording] = [] more_recordings_available = False team = context["get_team"]() @@ -510,18 +511,16 @@ def list_recordings(filter: SessionRecordingsFilter, request: request.Request, c # If we specify the session ids (like from pinned recordings) we can optimise by only going to Postgres sorted_session_ids = sorted(all_session_ids) - persisted_recordings_queryset = ( - SessionRecording.objects.filter(team=team, session_id__in=sorted_session_ids) - .exclude(object_storage_path=None) - .annotate(pinned_count=Count("playlist_items")) - ) + persisted_recordings_queryset = SessionRecording.objects.filter( + team=team, session_id__in=sorted_session_ids + ).exclude(object_storage_path=None) persisted_recordings = persisted_recordings_queryset.all() recordings = recordings + list(persisted_recordings) remaining_session_ids = list(set(all_session_ids) - {x.session_id for x in persisted_recordings}) - filter = filter.shallow_clone({SESSION_RECORDINGS_FILTER_IDS: json.dumps(remaining_session_ids)}) + filter = filter.shallow_clone({SESSION_RECORDINGS_FILTER_IDS: remaining_session_ids}) if (all_session_ids and filter.session_ids) or not all_session_ids: # Only go to clickhouse if we still have remaining specified IDs, or we are not specifying IDs diff --git a/posthog/session_recordings/test/test_session_recordings.py b/posthog/session_recordings/test/test_session_recordings.py index a535fba873f09..03b495047c9f9 100644 --- a/posthog/session_recordings/test/test_session_recordings.py +++ b/posthog/session_recordings/test/test_session_recordings.py @@ -345,7 +345,6 @@ def test_get_single_session_recording_metadata(self): "id": "session_1", "distinct_id": "d1", "viewed": False, - "pinned_count": 0, "recording_duration": 30, "start_time": base_time.replace(tzinfo=timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"), "end_time": (base_time + relativedelta(seconds=30)).strftime("%Y-%m-%dT%H:%M:%SZ"), @@ -366,7 +365,7 @@ def test_get_single_session_recording_metadata(self): "created_at": "2023-01-01T12:00:00Z", "uuid": ANY, }, - "storage": "clickhouse", + "storage": "object_storage", } def test_get_default_limit_of_chunks(self): @@ -525,7 +524,6 @@ def test_empty_list_session_ids_filter_returns_no_recordings(self): self.assertEqual(len(response_data["results"]), 0) def test_regression_encoded_emojis_dont_crash(self): - Person.objects.create( team=self.team, distinct_ids=["user"], properties={"$some_prop": "something", "email": "bob@bob.com"} ) @@ -564,6 +562,16 @@ def test_delete_session_recording(self): response = self.client.delete(f"/api/projects/{self.team.id}/session_recordings/1") self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + def test_persist_session_recording(self): + self.create_snapshot("user", "1", now() - relativedelta(days=1), team_id=self.team.pk) + response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/1") + assert response.json()["storage"] == "object_storage" + # Trying to delete same recording again returns 404 + response = self.client.post(f"/api/projects/{self.team.id}/session_recordings/1/persist") + assert response.json()["success"] + response = self.client.get(f"/api/projects/{self.team.id}/session_recordings/1") + assert response.json()["storage"] == "object_storage_lts" + # New snapshot loading method @freeze_time("2023-01-01T00:00:00Z") @patch("posthog.session_recordings.session_recording_api.object_storage.list_objects") diff --git a/posthog/tasks/usage_report.py b/posthog/tasks/usage_report.py index b9164dd6cf690..b150a75f88f12 100644 --- a/posthog/tasks/usage_report.py +++ b/posthog/tasks/usage_report.py @@ -542,7 +542,6 @@ def get_teams_with_survey_responses_count_in_period( begin: datetime, end: datetime, ) -> List[Tuple[int, int]]: - results = sync_execute( """ SELECT team_id, COUNT() as count