diff --git a/frontend/__snapshots__/components-playlist--default--dark.png b/frontend/__snapshots__/components-playlist--default--dark.png new file mode 100644 index 0000000000000..88b23fc64f8e8 Binary files /dev/null and b/frontend/__snapshots__/components-playlist--default--dark.png differ diff --git a/frontend/__snapshots__/components-playlist--default--light.png b/frontend/__snapshots__/components-playlist--default--light.png new file mode 100644 index 0000000000000..8a46d7ab3637f Binary files /dev/null and b/frontend/__snapshots__/components-playlist--default--light.png differ diff --git a/frontend/__snapshots__/components-playlist--multiple-sections--dark.png b/frontend/__snapshots__/components-playlist--multiple-sections--dark.png new file mode 100644 index 0000000000000..c32bc9749df03 Binary files /dev/null and b/frontend/__snapshots__/components-playlist--multiple-sections--dark.png differ diff --git a/frontend/__snapshots__/components-playlist--multiple-sections--light.png b/frontend/__snapshots__/components-playlist--multiple-sections--light.png new file mode 100644 index 0000000000000..a5efa06274e97 Binary files /dev/null and b/frontend/__snapshots__/components-playlist--multiple-sections--light.png differ diff --git a/frontend/__snapshots__/components-playlist--with-footer--dark.png b/frontend/__snapshots__/components-playlist--with-footer--dark.png new file mode 100644 index 0000000000000..88b23fc64f8e8 Binary files /dev/null and b/frontend/__snapshots__/components-playlist--with-footer--dark.png differ diff --git a/frontend/__snapshots__/components-playlist--with-footer--light.png b/frontend/__snapshots__/components-playlist--with-footer--light.png new file mode 100644 index 0000000000000..8a46d7ab3637f Binary files /dev/null and b/frontend/__snapshots__/components-playlist--with-footer--light.png differ diff --git a/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--dark.png b/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--dark.png index a820c399ecbf2..f427261f0c801 100644 Binary files a/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--dark.png and b/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--dark.png differ diff --git a/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--light.png b/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--light.png index 1276a2564f958..fa79b1b313e1d 100644 Binary files a/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--light.png and b/frontend/__snapshots__/exporter-exporter--funnel-historical-trends-insight--light.png differ diff --git a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png index 688871da10a15..7c36f82f3c19e 100644 Binary files a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png and b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--dark.png differ diff --git a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png index 1b812d9edd438..e07d3b8776663 100644 Binary files a/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png and b/frontend/__snapshots__/replay-player-failure--recent-recordings-404--light.png differ diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss b/frontend/src/lib/components/Playlist/Playlist.scss similarity index 75% rename from frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss rename to frontend/src/lib/components/Playlist/Playlist.scss index a7df986ce52db..292fe2f7b3629 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylist.scss +++ b/frontend/src/lib/components/Playlist/Playlist.scss @@ -1,7 +1,7 @@ @import '../../../styles/mixins'; @import '../../../styles/vars'; -.SessionRecordingsPlaylist { +.Playlist { display: flex; flex-direction: row; align-items: flex-start; @@ -11,7 +11,7 @@ border: 1px solid var(--border); border-radius: var(--radius); - .SessionRecordingsPlaylist__list { + .Playlist__list { position: relative; display: flex; flex-direction: column; @@ -19,7 +19,7 @@ height: 100%; overflow: hidden; - &:not(.SessionRecordingsPlaylist__list--collapsed) { + &:not(.Playlist__list--collapsed) { width: 25%; min-width: 305px; max-width: 350px; @@ -30,18 +30,11 @@ } } - .SessionRecordingsPlaylist__player { + .Playlist__main { flex: 1; width: 100%; height: 100%; overflow: hidden; - - .SessionRecordingsPlaylist__loading { - display: flex; - align-items: center; - justify-content: center; - margin-top: 10rem; - } } &--embedded { @@ -49,7 +42,7 @@ } &--wide { - .SessionRecordingsPlaylist__player { + .Playlist__main { flex: 1; height: 100%; } diff --git a/frontend/src/lib/components/Playlist/Playlist.stories.tsx b/frontend/src/lib/components/Playlist/Playlist.stories.tsx new file mode 100644 index 0000000000000..0fdc98cd1d4bc --- /dev/null +++ b/frontend/src/lib/components/Playlist/Playlist.stories.tsx @@ -0,0 +1,79 @@ +import { Meta, StoryFn, StoryObj } from '@storybook/react' +import { range } from 'lib/utils' + +import { Playlist, PlaylistProps } from './Playlist' + +type Story = StoryObj +const meta: Meta = { + title: 'Components/Playlist', + component: Playlist, +} +export default meta + +type ObjectType = { id: string | number } + +const ListItem = ({ item }: { item: ObjectType }): JSX.Element =>
Object {item.id}
+ +const Template: StoryFn = (props: Partial>) => { + const mainContent = ({ activeItem }: { activeItem: ObjectType }): JSX.Element => ( +
+ {activeItem ? `Object ${activeItem.id} selected` : 'Select an item from the list'} +
+ ) + + return ( +
+ No items
} + content={mainContent} + {...props} + /> + + ) +} + +export const Default: Story = Template.bind({}) +Default.args = { + sections: [ + { + key: 'default', + title: 'Default section', + items: range(0, 100).map((idx) => ({ id: idx })), + render: ListItem, + }, + ], +} + +export const MultipleSections: Story = Template.bind({}) +MultipleSections.args = { + sections: [ + { + key: 'one', + title: 'First section', + items: range(0, 5).map((idx) => ({ id: idx })), + render: ListItem, + initiallyOpen: true, + }, + { + key: 'two', + title: 'Second section', + items: range(0, 5).map((idx) => ({ id: idx })), + render: ListItem, + }, + ], +} + +export const WithFooter: Story = Template.bind({}) +WithFooter.args = { + sections: [ + { + key: 'default', + title: 'Section with footer', + items: range(0, 100).map((idx) => ({ id: idx })), + render: ListItem, + footer:
Section footer
, + }, + ], +} diff --git a/frontend/src/lib/components/Playlist/Playlist.tsx b/frontend/src/lib/components/Playlist/Playlist.tsx new file mode 100644 index 0000000000000..42acac30276b0 --- /dev/null +++ b/frontend/src/lib/components/Playlist/Playlist.tsx @@ -0,0 +1,309 @@ +import './Playlist.scss' + +import { IconCollapse } from '@posthog/icons' +import { LemonButton, LemonButtonProps, LemonCollapse, LemonSkeleton, Tooltip } from '@posthog/lemon-ui' +import clsx from 'clsx' +import { useResizeBreakpoints } from 'lib/hooks/useResizeObserver' +import { IconChevronRight } from 'lib/lemon-ui/icons' +import { LemonTableLoader } from 'lib/lemon-ui/LemonTable/LemonTableLoader' +import { range } from 'lib/utils' +import { useEffect, useRef, useState } from 'react' +import { DraggableToNotebook } from 'scenes/notebooks/AddToNotebook/DraggableToNotebook' + +import { Resizer } from '../Resizer/Resizer' + +const SCROLL_TRIGGER_OFFSET = 100 + +export type PlaylistSection = { + key: string + title?: string + items: T[] + render: ({ item, isActive }: { item: T; isActive: boolean }) => JSX.Element + initiallyOpen?: boolean + footer?: JSX.Element +} + +type PlaylistHeaderAction = Pick & { + key: string + content: React.ReactNode +} + +export type PlaylistProps = { + sections: PlaylistSection[] + listEmptyState: JSX.Element + content: ({ activeItem }: { activeItem: T | null }) => JSX.Element + title?: string + notebooksHref?: string + embedded?: boolean + loading?: boolean + headerActions?: PlaylistHeaderAction[] + onScrollListEdge?: (edge: 'top' | 'bottom') => void + onSelect?: (item: T) => void + 'data-attr'?: string + activeItemId?: string +} + +const CounterBadge = ({ children }: { children: React.ReactNode }): JSX.Element => ( + {children} +) + +export function Playlist< + T extends { + id: string | number // accepts any object as long as it conforms to the interface of having an `id` + [key: string]: any + } +>({ + title, + notebooksHref, + loading, + embedded = false, + activeItemId: propsActiveItemId, + content, + sections, + headerActions = [], + onScrollListEdge, + listEmptyState, + onSelect, + 'data-attr': dataAttr, +}: PlaylistProps): JSX.Element { + const [controlledActiveItemId, setControlledActiveItemId] = useState(null) + const [listCollapsed, setListCollapsed] = useState(false) + const playlistListRef = useRef(null) + const { ref: playlistRef, size } = useResizeBreakpoints({ + 0: 'small', + 750: 'medium', + }) + + const onChangeActiveItem = (item: T): void => { + setControlledActiveItemId(item.id) + onSelect?.(item) + } + + const activeItemId = propsActiveItemId === undefined ? controlledActiveItemId : propsActiveItemId + + const activeItem = sections.flatMap((s) => s.items).find((i) => i.id === activeItemId) || null + + return ( +
+
+ {listCollapsed ? ( + setListCollapsed(false)} /> + ) : ( + setListCollapsed(true)} + activeItemId={activeItemId} + setActiveItemId={onChangeActiveItem} + emptyState={listEmptyState} + /> + )} + setListCollapsed(value)} + onDoubleClick={() => setListCollapsed(!listCollapsed)} + /> +
+
{content({ activeItem })}
+
+ ) +} + +const CollapsedList = ({ onClickOpen }: { onClickOpen: () => void }): JSX.Element => ( +
+ } onClick={onClickOpen} /> +
+) + +function List< + T extends { + id: string | number + [key: string]: any + } +>({ + title, + notebooksHref, + onClickCollapse, + setActiveItemId, + headerActions = [], + sections, + activeItemId, + onScrollListEdge, + loading, + emptyState, +}: { + title: PlaylistProps['title'] + notebooksHref: PlaylistProps['notebooksHref'] + onClickCollapse: () => void + activeItemId: T['id'] | null + setActiveItemId: (item: T) => void + headerActions: PlaylistProps['headerActions'] + sections: PlaylistProps['sections'] + onScrollListEdge: PlaylistProps['onScrollListEdge'] + loading: PlaylistProps['loading'] + emptyState: PlaylistProps['listEmptyState'] +}): JSX.Element { + const [activeHeaderActionKey, setActiveHeaderActionKey] = useState(null) + const lastScrollPositionRef = useRef(0) + const contentRef = useRef(null) + + useEffect(() => { + if (contentRef.current) { + contentRef.current.scrollTop = 0 + } + }, [activeHeaderActionKey]) + + 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) { + onScrollListEdge?.('bottom') + } + } + + // Same again but if scrolling to the top + if (e.currentTarget.scrollTop < lastScrollPositionRef.current) { + if (e.currentTarget.scrollTop < SCROLL_TRIGGER_OFFSET) { + onScrollListEdge?.('top') + } + } + + lastScrollPositionRef.current = e.currentTarget.scrollTop + } + + const itemsCount = sections.flatMap((s) => s.items).length + const actionContent = headerActions?.find((a) => activeHeaderActionKey === a.key)?.content + const initiallyOpenSections = sections.filter((s) => s.initiallyOpen).map((s) => s.key) + + return ( +
+ +
+ } onClick={onClickCollapse} /> + + {title ? ( + + {title} + + ) : null} + + Showing {itemsCount} results. +
+ Scrolling to the bottom or the top of the list will load older or newer results + respectively. + + } + > + + {Math.min(999, itemsCount)}+ + +
+
+ {headerActions.map(({ key, icon, tooltip, children }) => ( + setActiveHeaderActionKey(activeHeaderActionKey === key ? null : key)} + > + {children} + + ))} + +
+
+ +
+ {actionContent &&
{actionContent}
} + + {sections.flatMap((s) => s.items).length ? ( + <> + {sections.length > 1 ? ( + ({ + key: s.key, + header: s.title, + content: ( + + ), + className: 'p-0', + }))} + multiple + embedded + size="small" + /> + ) : ( + + )} + + ) : loading ? ( + + ) : ( + emptyState + )} +
+
+ ) +} + +export function ListSection< + T extends { + id: string | number + [key: string]: any + } +>({ + items, + render, + footer, + onClick, + activeItemId, +}: PlaylistSection & { + onClick: (item: T) => void + activeItemId: T['id'] | null +}): JSX.Element { + return ( + <> + {items.length && + items.map((item) => ( +
onClick(item)}> + {render({ item, isActive: item.id === activeItemId })} +
+ ))} + {footer} + + ) +} + +const LoadingState = (): JSX.Element => { + return ( + <> + {range(20).map((i) => ( +
+ + +
+ ))} + + ) +} diff --git a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss index 01aa63775fcf9..6e7dfba7a766c 100644 --- a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss +++ b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss @@ -5,6 +5,11 @@ overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); + + &--embedded { + border: none; + border-radius: 0; + } } .LemonCollapsePanel { diff --git a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx index 55b9743c907bd..96e8105c60133 100644 --- a/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx +++ b/frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx @@ -21,6 +21,7 @@ interface LemonCollapsePropsBase { panels: (LemonCollapsePanel | null | false)[] className?: string size?: LemonButtonProps['size'] + embedded?: boolean } interface LemonCollapsePropsSingle extends LemonCollapsePropsBase { @@ -43,6 +44,7 @@ export function LemonCollapse({ panels, className, size, + embedded, ...props }: LemonCollapseProps): JSX.Element { let isPanelExpanded: (key: K) => boolean @@ -72,7 +74,7 @@ export function LemonCollapse({ } return ( -
+
{(panels.filter(Boolean) as LemonCollapsePanel[]).map(({ key, ...panel }) => ( ( - {children} -) - -function UnusableEventsWarning(props: { unusableEventsInFilter: string[] }): JSX.Element { - // TODO add docs on how to enrich custom events with session_id and link to it from here - return ( - -

Cannot use these events to filter for session recordings:

-
  • - {props.unusableEventsInFilter.map((event) => ( - "{event}" - ))} -
  • -

    - Events have to have a to be used to filter recordings. This is - added automatically by{' '} - - the Web SDK - - ,{' '} - - the Android SDK - -

    -
    - ) -} - -function PinnedRecordingsList(): JSX.Element | null { - const { setSelectedRecordingId } = useActions(sessionRecordingsPlaylistLogic) - const { activeSessionRecordingId, pinnedRecordings } = useValues(sessionRecordingsPlaylistLogic) - - const { featureFlags } = useValues(featureFlagLogic) - const isTestingSaved = featureFlags[FEATURE_FLAGS.SAVED_NOT_PINNED] === 'test' - - const description = isTestingSaved ? 'Saved' : 'Pinned' - - if (!pinnedRecordings.length) { - return null +export function SessionRecordingsPlaylist(props: SessionRecordingPlaylistLogicProps): JSX.Element { + const logicProps: SessionRecordingPlaylistLogicProps = { + ...props, + autoPlay: props.autoPlay ?? true, } - - return ( - <> -
    - {description} recordings -
    - {pinnedRecordings.map((rec) => ( -
    - setSelectedRecordingId(rec.id)} - isActive={activeSessionRecordingId === rec.id} - pinned={true} - /> -
    - ))} - - ) -} - -function RecordingsLists(): JSX.Element { + const logic = sessionRecordingsPlaylistLogic(logicProps) const { filters, - advancedFilters, - simpleFilters, - hasNext, pinnedRecordings, - otherRecordings, - sessionRecordingsResponseLoading, - activeSessionRecordingId, - showFilters, - showSettings, totalFiltersCount, - sessionRecordingsAPIErrored, - unusableEventsInFilter, - logicProps, - showOtherRecordings, - recordingsCount, - isRecordingsListCollapsed, - sessionSummaryLoading, useUniversalFiltering, - } = useValues(sessionRecordingsPlaylistLogic) + matchingEventsMatchType, + sessionRecordingsResponseLoading, + otherRecordings, + sessionSummaryLoading, + advancedFilters, + simpleFilters, + activeSessionRecordingId, + hasNext, + } = useValues(logic) const { + maybeLoadSessionRecordings, + summarizeSession, setSelectedRecordingId, setAdvancedFilters, setSimpleFilters, - maybeLoadSessionRecordings, - setShowFilters, - setShowSettings, resetFilters, - toggleShowOtherRecordings, - toggleRecordingsListCollapsed, - summarizeSession, - } = useActions(sessionRecordingsPlaylistLogic) - - const onRecordingClick = (recording: SessionRecordingType): void => { - setSelectedRecordingId(recording.id) - } - - const onSummarizeClick = (recording: SessionRecordingType): void => { - summarizeSession(recording.id) - } - - const lastScrollPositionRef = useRef(0) - const contentRef = useRef(null) - - 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') - } - } + } = useActions(logic) - lastScrollPositionRef.current = e.currentTarget.scrollTop - } + const { featureFlags } = useValues(featureFlagLogic) + const isTestingSaved = featureFlags[FEATURE_FLAGS.SAVED_NOT_PINNED] === 'test' - useEffect(() => { - if (contentRef.current) { - contentRef.current.scrollTop = 0 - } - }, [showFilters, showSettings]) + const pinnedDescription = isTestingSaved ? 'Saved' : 'Pinned' const notebookNode = useNotebookNode() - return isRecordingsListCollapsed ? ( -
    - } onClick={() => toggleRecordingsListCollapsed()} /> -
    - ) : ( -
    - -
    - } - onClick={() => toggleRecordingsListCollapsed()} - /> - - {!notebookNode ? ( - - Recordings - - ) : null} - - Showing {recordingsCount} results. -
    - Scrolling to the bottom or the top of the list will load older or newer recordings - respectively. - - } - > - - {Math.min(999, recordingsCount)}+ - -
    -
    - {(!useUniversalFiltering || notebookNode) && ( - - - - } - onClick={() => { - if (notebookNode) { - notebookNode.actions.toggleEditing() - } else { - setShowFilters(!showFilters) - } - }} - > - Filter - - )} - } - onClick={() => setShowSettings(!showSettings)} - /> - -
    -
    + const sections: PlaylistSection[] = [] + const headerActions = [] -
    - {!notebookNode && showFilters ? ( -
    - -
    - ) : showSettings ? ( - - ) : null} + const onSummarizeClick = (recording: SessionRecordingType): void => { + summarizeSession(recording.id) + } - {pinnedRecordings.length || otherRecordings.length ? ( -
      - + if (!useUniversalFiltering || notebookNode) { + headerActions.push({ + key: 'filters', + tooltip: 'Filter recordings', + content: ( + + ), + icon: ( + + + + ), + children: 'Filter', + }) + } - {pinnedRecordings.length ? ( -
      - Other recordings - toggleShowOtherRecordings()}> - {showOtherRecordings ? 'Hide' : 'Show'} - -
      - ) : null} + headerActions.push({ + key: 'settings', + tooltip: 'Playlist settings', + content: , + icon: , + }) - <> - {showOtherRecordings - ? otherRecordings.map((rec) => ( -
      - onRecordingClick(rec)} - isActive={activeSessionRecordingId === rec.id} - pinned={false} - summariseFn={onSummarizeClick} - sessionSummaryLoading={sessionSummaryLoading} - /> -
      - )) - : null} + if (pinnedRecordings.length) { + sections.push({ + key: 'pinned', + title: `${pinnedDescription} recordings`, + items: pinnedRecordings, + render: ({ item, isActive }) => ( + + ), + initiallyOpen: true, + }) + } -
      - {!showOtherRecordings && totalFiltersCount ? ( - <>Filters do not apply to pinned recordings - ) : sessionRecordingsResponseLoading ? ( - <> - Loading older recordings - - ) : hasNext ? ( - maybeLoadSessionRecordings('older')}> - Load more - - ) : ( - 'No more results' - )} -
      - -
    - ) : sessionRecordingsResponseLoading ? ( + sections.push({ + key: 'other', + title: 'Other recordings', + items: otherRecordings, + render: ({ item, isActive }) => ( + + ), + footer: ( +
    + {sessionRecordingsResponseLoading ? ( <> - {range(RECORDINGS_LIMIT).map((i) => ( - - ))} + Loading older recordings + ) : hasNext ? ( + maybeLoadSessionRecordings('older')}>Load more ) : ( -
    - {sessionRecordingsAPIErrored ? ( - Error while trying to load recordings. - ) : unusableEventsInFilter.length ? ( - - ) : ( -
    - {filters.date_from === DEFAULT_RECORDING_FILTERS.date_from ? ( - <> - No matching recordings found - { - setAdvancedFilters({ - date_from: '-30d', - }) - }} - > - Search over the last 30 days - - - ) : ( - - )} -
    - )} -
    + 'No more results' )}
    -
    - ) -} - -export function SessionRecordingsPlaylist(props: SessionRecordingPlaylistLogicProps): JSX.Element { - const logicProps: SessionRecordingPlaylistLogicProps = { - ...props, - autoPlay: props.autoPlay ?? true, - } - const playlistRecordingsListRef = useRef(null) - const logic = sessionRecordingsPlaylistLogic(logicProps) - const { - activeSessionRecording, - activeSessionRecordingId, - matchingEventsMatchType, - pinnedRecordings, - isRecordingsListCollapsed, - useUniversalFiltering, - } = useValues(logic) - const { toggleRecordingsListCollapsed } = useActions(logic) - - const { ref: playlistRef, size } = useResizeBreakpoints({ - 0: 'small', - 750: 'medium', + ), }) - const notebookNode = useNotebookNode() - return (
    {useUniversalFiltering && } - -
    -
    - - toggleRecordingsListCollapsed(shouldBeClosed)} - onDoubleClick={() => toggleRecordingsListCollapsed()} - /> -
    -
    - {!activeSessionRecordingId ? ( -
    - -
    - ) : ( + notebooksHref={urls.replay(ReplayTabs.Recent, filters)} + title={!notebookNode ? 'Recordings' : undefined} + embedded={!!notebookNode} + sections={sections} + headerActions={headerActions} + loading={sessionRecordingsResponseLoading} + onScrollListEdge={(edge) => { + if (edge === 'top') { + maybeLoadSessionRecordings('newer') + } else { + maybeLoadSessionRecordings('older') + } + }} + listEmptyState={} + onSelect={(item) => setSelectedRecordingId(item.id)} + activeItemId={activeSessionRecordingId} + content={({ activeItem }) => + activeItem ? ( x.id === activeSessionRecordingId)} + pinned={!!pinnedRecordings.find((x) => x.id === activeItem.id)} setPinned={ props.onPinnedChange ? (pinned) => { - if (!activeSessionRecording?.id) { + if (!activeItem.id) { return } - props.onPinnedChange?.(activeSessionRecording, pinned) + props.onPinnedChange?.(activeItem, pinned) } : undefined } /> - )} -
    -
    + ) : ( +
    + +
    + ) + } + />
    ) } + +const ListEmptyState = (): JSX.Element => { + const { filters, sessionRecordingsAPIErrored, unusableEventsInFilter } = useValues(sessionRecordingsPlaylistLogic) + const { setAdvancedFilters } = useActions(sessionRecordingsPlaylistLogic) + + return ( +
    + {sessionRecordingsAPIErrored ? ( + Error while trying to load recordings. + ) : unusableEventsInFilter.length ? ( + + ) : ( +
    + {filters.date_from === DEFAULT_RECORDING_FILTERS.date_from ? ( + <> + No matching recordings found + { + setAdvancedFilters({ + date_from: '-30d', + }) + }} + > + Search over the last 30 days + + + ) : ( + + )} +
    + )} +
    + ) +} + +function UnusableEventsWarning(props: { unusableEventsInFilter: string[] }): JSX.Element { + // TODO add docs on how to enrich custom events with session_id and link to it from here + return ( + +

    Cannot use these events to filter for session recordings:

    +
  • + {props.unusableEventsInFilter.map((event) => ( + "{event}" + ))} +
  • +

    + Events have to have a to be used to filter recordings. This is + added automatically by{' '} + + the Web SDK + + ,{' '} + + the Android SDK + +

    +
    + ) +} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx index b9a504d182821..01be80946d2c8 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistScene.tsx @@ -1,5 +1,3 @@ -import './SessionRecordingsPlaylist.scss' - import { LemonButton, LemonDivider } from '@posthog/lemon-ui' import { useActions, useValues } from 'kea' import { EditableField } from 'lib/components/EditableField/EditableField' diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx index be48802d9c7c4..972628dd0032b 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx @@ -12,7 +12,7 @@ export function SessionRecordingsPlaylistSettings(): JSX.Element { const { orderBy } = useValues(sessionRecordingsPlaylistLogic) return ( -
    +
    diff --git a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts index bf0807e092fac..0b66d4bb34062 100644 --- a/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts +++ b/frontend/src/scenes/session-recordings/playlist/sessionRecordingsPlaylistLogic.ts @@ -25,7 +25,6 @@ import { RecordingDurationFilter, RecordingFilters, RecordingUniversalFilters, - ReplayTabs, SessionRecordingId, SessionRecordingsResponse, SessionRecordingType, @@ -186,7 +185,6 @@ export interface SessionRecordingPlaylistLogicProps { onFiltersChange?: (filters: RecordingFilters) => void pinnedRecordings?: (SessionRecordingType | string)[] onPinnedChange?: (recording: SessionRecordingType, pinned: boolean) => void - currentTab?: ReplayTabs } export interface SessionSummaryResponse { @@ -201,6 +199,7 @@ export const sessionRecordingsPlaylistLogic = kea `${props.logicKey}-${props.personUUID}-${props.updateSearchParams ? '-with-search' : ''}` ), + connect({ actions: [ eventUsageLogic, @@ -215,6 +214,7 @@ export const sessionRecordingsPlaylistLogic = kea) => ({ filters }), setAdvancedFilters: (filters: Partial) => ({ filters }), @@ -234,7 +234,6 @@ export const sessionRecordingsPlaylistLogic = kea ({ show }), - toggleRecordingsListCollapsed: (override?: boolean) => ({ override }), }), propsChanged(({ actions, props }, oldProps) => { if (!objectsEqual(props.advancedFilters, oldProps.advancedFilters)) { @@ -514,13 +513,6 @@ export const sessionRecordingsPlaylistLogic = kea false, }, ], - isRecordingsListCollapsed: [ - false, - { persist: true }, - { - toggleRecordingsListCollapsed: (state, { override }) => override ?? !state, - }, - ], })), listeners(({ props, actions, values }) => ({ loadAllRecordings: () => {