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 5356f64192dd5..06866ae4e420f 100644 Binary files a/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png and b/frontend/__snapshots__/scenes-app-notebooks--recordings-playlist.png differ 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