diff --git a/frontend/src/scenes/session-recordings/playlist/OngoingRecordingsGroup.tsx b/frontend/src/scenes/session-recordings/playlist/OngoingRecordingsGroup.tsx index cc3a128e762f..aea22fc961dd 100644 --- a/frontend/src/scenes/session-recordings/playlist/OngoingRecordingsGroup.tsx +++ b/frontend/src/scenes/session-recordings/playlist/OngoingRecordingsGroup.tsx @@ -1,7 +1,7 @@ import { ReactNode, useState } from 'react' -import { IconChevronDown, IconChevronRight, IconLive } from '@posthog/icons' -import { LemonBadge, LemonButton } from '@posthog/lemon-ui' +import { IconLive } from '@posthog/icons' +import { Badge, Collapsible, CollapsibleContent, CollapsibleTrigger } from '@posthog/quill' export function OngoingRecordingsGroup({ count, @@ -29,22 +29,21 @@ export function OngoingRecordingsGroup({ const expanded = choice ?? (holdsActive || defaultOpen) return ( -
- } - sideIcon={expanded ? : } - onClick={() => setChoice(!expanded)} - data-attr="list-ongoing-toggle" - > - + setChoice(open)} + className="border-b border-border" + > + + + Still recording - + {count} - - {expanded && children} -
+ + + {children} + ) } diff --git a/frontend/src/scenes/session-recordings/playlist/Playlist.tsx b/frontend/src/scenes/session-recordings/playlist/Playlist.tsx index e09e05debb58..70dabad3a57f 100644 --- a/frontend/src/scenes/session-recordings/playlist/Playlist.tsx +++ b/frontend/src/scenes/session-recordings/playlist/Playlist.tsx @@ -386,7 +386,7 @@ export function Playlist({ setPlaylistCollapsed(true)} type={type} shortId={type === 'collection' ? logicKey : undefined} /> diff --git a/frontend/src/scenes/session-recordings/playlist/QuillMenuItems.tsx b/frontend/src/scenes/session-recordings/playlist/QuillMenuItems.tsx new file mode 100644 index 000000000000..64e8b68fbdab --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/QuillMenuItems.tsx @@ -0,0 +1,199 @@ +import { Fragment, ReactNode } from 'react' + +import { + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, +} from '@posthog/quill' + +import { + LemonMenuItem, + LemonMenuItemCustom, + LemonMenuItemNode, + LemonMenuItems, + LemonMenuSection, +} from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { LinkPrimitive } from 'lib/lemon-ui/Link' + +type PresentItem = Exclude +type MenuEntry = Exclude +type MenuLeaf = Exclude + +function tooltipText(tooltip: MenuEntry['tooltip']): ReactNode { + return typeof tooltip === 'function' ? tooltip() : tooltip +} + +function isSection(item: PresentItem): item is LemonMenuSection { + return !('label' in item) +} + +function isCustom(item: LemonMenuItem): item is LemonMenuItemCustom { + return typeof item.label === 'function' +} + +function isNode(item: MenuEntry): item is LemonMenuItemNode { + return Array.isArray(item.items) +} + +function itemKey(item: LemonMenuItem, index: number): string { + if (isCustom(item)) { + return String(item.key ?? index) + } + return String(item.key ?? item['data-attr'] ?? (typeof item.label === 'string' ? item.label : index)) +} + +/** Renders Lemon menu item definitions as quill dropdown menu content, so the Lemon and quill menus share one list. */ +export function QuillMenuItems({ + items, + closeOnClick = true, +}: { + items: LemonMenuItems + closeOnClick?: boolean +}): JSX.Element { + const sections: LemonMenuSection[] = [] + for (const item of items) { + if (!item) { + continue + } + if (isSection(item)) { + sections.push(item) + } else if ( + sections.length && + !sections[sections.length - 1].title && + !('key' in sections[sections.length - 1]) + ) { + sections[sections.length - 1].items.push(item) + } else { + sections.push({ items: [item] }) + } + } + + return ( + <> + {sections.map((section, index) => ( + + {index > 0 && } + + {section.title && {section.title}} + !!item)} + closeOnClick={closeOnClick} + /> + {section.footer} + + + ))} + + ) +} + +function SectionItems({ items, closeOnClick }: { items: LemonMenuItem[]; closeOnClick: boolean }): JSX.Element { + const exclusive = items.some((item) => 'active' in item && item.active !== undefined) + if (!exclusive) { + return ( + <> + {items.map((item, index) => ( + + ))} + + ) + } + const activeIndex = items.findIndex((item) => 'active' in item && item.active) + return ( + = 0 ? itemKey(items[activeIndex], activeIndex) : ''}> + {items.map((item, index) => ( + + ))} + + ) +} + +function Entry({ + item, + closeOnClick, + radioValue, +}: { + item: LemonMenuItem + closeOnClick: boolean + radioValue?: string +}): JSX.Element { + if (isCustom(item)) { + return item.label() + } + const entry: MenuEntry = item + const content = ( + <> + {entry.icon} + {entry.label} + {entry.sideIcon} + + ) + if (isNode(entry)) { + return ( + + + {content} + + + + + + ) + } + const leaf: MenuLeaf = entry + if (radioValue !== undefined) { + return ( + + {content} + + ) + } + return ( + + ), + } + : {})} + > + {content} + + ) +} diff --git a/frontend/src/scenes/session-recordings/playlist/ReplayBulkBar.tsx b/frontend/src/scenes/session-recordings/playlist/ReplayBulkBar.tsx index 2ef73c1247d1..7f83f199d69c 100644 --- a/frontend/src/scenes/session-recordings/playlist/ReplayBulkBar.tsx +++ b/frontend/src/scenes/session-recordings/playlist/ReplayBulkBar.tsx @@ -1,15 +1,17 @@ import { useActions, useValues } from 'kea' -import { IconEllipsis } from '@posthog/icons' -import { LemonButton } from '@posthog/lemon-ui' +import { IconEllipsis, IconEye } from '@posthog/icons' +import { Button, DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from '@posthog/quill' -import { LemonMenu, LemonMenuItem, LemonMenuItemLeafCallback } from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { LemonMenuItem, LemonMenuItemLeafCallback } from 'lib/lemon-ui/LemonMenu/LemonMenu' import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' import { AccessControlLevel, AccessControlResourceType } from '~/types' +import { QuillMenuItems } from './QuillMenuItems' import { sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' -import { AddToCollectionModal, BulkScanMenuItem, ConfirmDeleteRecordings } from './SessionRecordingsPlaylistSettings' +import { AddToCollectionModal, ConfirmDeleteRecordings } from './SessionRecordingsPlaylistSettings' +import { useBulkScanMenuItems } from './useBulkScanMenuItems' export function ReplayBulkBar({ type, shortId }: { type: 'filters' | 'collection'; shortId?: string }): JSX.Element { const { selectedRecordingsIds } = useValues(sessionRecordingsPlaylistLogic) @@ -20,6 +22,7 @@ export function ReplayBulkBar({ type, shortId }: { type: 'filters' | 'collection handleBulkMarkAsNotViewed, handleBulkDeleteFromPlaylist, } = useActions(sessionRecordingsPlaylistLogic) + const bulkScan = useBulkScanMenuItems() const accessControlDisabledReason = getAccessControlDisabledReason( AccessControlResourceType.SessionRecording, AccessControlLevel.Editor @@ -40,7 +43,14 @@ export function ReplayBulkBar({ type, shortId }: { type: 'filters' | 'collection }, ] : []), - { key: 'bulk-scan-recordings', label: () => , custom: true }, + { + label: 'Scan these recordings', + icon: , + items: bulkScan.items, + disabledReason: bulkScan.disabledReason, + tooltip: bulkScan.tooltip, + 'data-attr': 'vision-bulk-scan-recordings', + }, ] const primaryItems: LemonMenuItemLeafCallback[] = [ @@ -65,35 +75,38 @@ export function ReplayBulkBar({ type, shortId }: { type: 'filters' | 'collection {selectedRecordingsIds.length} selected
{primaryItems.map((item) => ( - {item.label} - + ))} - - } - aria-label="More actions" - data-attr="bulk-action-menu" - /> - + + } + > + + + + + +
- - } - aria-label="Bulk actions" - data-attr="bulk-action-menu-narrow" - /> - + + } + > + + + + + +
diff --git a/frontend/src/scenes/session-recordings/playlist/ReplayListHeader.tsx b/frontend/src/scenes/session-recordings/playlist/ReplayListHeader.tsx index 0eb8c72735d5..adcc77ccbde5 100644 --- a/frontend/src/scenes/session-recordings/playlist/ReplayListHeader.tsx +++ b/frontend/src/scenes/session-recordings/playlist/ReplayListHeader.tsx @@ -1,14 +1,22 @@ import { useActions, useValues } from 'kea' -import { ReactNode, useEffect } from 'react' +import { useEffect } from 'react' -import { IconCheckbox, IconChevronDown, IconGear } from '@posthog/icons' -import { LemonButton, LemonCheckbox, LemonDivider } from '@posthog/lemon-ui' - -import { LemonMenu } from 'lib/lemon-ui/LemonMenu/LemonMenu' -import { Tooltip } from 'lib/lemon-ui/Tooltip' +import { IconCheckbox, IconChevronDown, IconGear, IconSidebarClose } from '@posthog/icons' +import { + Button, + Checkbox, + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Separator, + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@posthog/quill' import { RecordingUniversalFilters } from '~/types' +import { QuillMenuItems } from './QuillMenuItems' import { ReplayBulkBar } from './ReplayBulkBar' import { MAX_SELECTED_RECORDINGS, sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' import { useWatchSettingsMenuItems, useWatchViewMenuItems, watchSortLabel } from './watchListViewMenuItems' @@ -16,13 +24,13 @@ import { useWatchSettingsMenuItems, useWatchViewMenuItems, watchSortLabel } from export function ReplayListHeader({ filters, setFilters, - collapseButton, + onCollapse, type = 'filters', shortId, }: { filters: RecordingUniversalFilters setFilters: (filters: Partial) => void - collapseButton: ReactNode + onCollapse: () => void type?: 'filters' | 'collection' shortId?: string }): JSX.Element { @@ -38,7 +46,7 @@ export function ReplayListHeader({ } const onKeyDown = (event: KeyboardEvent): void => { // An open dialog or menu owns Escape. - if (event.key === 'Escape' && !document.querySelector('.LemonModal, .Popover')) { + if (event.key === 'Escape' && !document.querySelector('.LemonModal, .Popover, [data-quill-portal]')) { setSelectMode(false) } } @@ -58,88 +66,91 @@ export function ReplayListHeader({ return (
{selectMode ? ( <> - - - + }> + + onCheckedChange={(checked) => setSelectedRecordingsIds(checked ? recordings.map((r) => r.id) : []) } - stopPropagation data-attr="select-all-recordings" aria-label="Select all recordings" /> - + + + {hasOngoing ? 'Select all finished recordings' : 'Select all recordings'} + {selectedRecordingsIds.length > 0 ? ( ) : ( - Select recordings + Select recordings )} - setSelectMode(false)} tooltip="Done selecting (Esc)" data-attr="list-select-done" > Done - + ) : ( <> - } +
- - } data-attr="list-view-menu"> + + }> {watchSortLabel(listSort.order ?? filters.order)} - - - + + + + + + + + } + > + + + + + + + +
)} diff --git a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx index 07de1a184211..002cee03ea98 100644 --- a/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx +++ b/frontend/src/scenes/session-recordings/playlist/SessionRecordingsPlaylistSettings.tsx @@ -1,5 +1,4 @@ import { useActions, useValues } from 'kea' -import { router } from 'kea-router' import posthog from 'posthog-js' import { IconCheck, IconChevronRight, IconEllipsis, IconEye, IconPlus, IconSort, IconTrash } from '@posthog/icons' @@ -12,14 +11,9 @@ import { LemonMenu, LemonMenuItem, LemonMenuItems } from 'lib/lemon-ui/LemonMenu import { getAccessControlDisabledReason } from 'lib/utils/accessControlUtils' import { matchesConfirmationText } from 'lib/utils/confirmationText' import { sessionRecordingCollectionsLogic } from 'scenes/session-recordings/collections/sessionRecordingCollectionsLogic' -import { urls } from 'scenes/urls' import { AccessControlLevel, AccessControlResourceType, RecordingUniversalFilters } from '~/types' -import { bulkScanLogic } from 'products/replay_vision/frontend/logics/bulkScanLogic' -import { visionQuotaLogic } from 'products/replay_vision/frontend/logics/visionQuotaLogic' -import { quotaUx } from 'products/replay_vision/frontend/utils/quotaProjection' - import { useAutoplayMenuItems, useHideRecordingsMenuItems, @@ -33,6 +27,7 @@ import { preferredRecordingsSortStorage, sessionRecordingsPlaylistLogic, } from './sessionRecordingsPlaylistLogic' +import { useBulkScanMenuItems } from './useBulkScanMenuItems' const SortingKeyToLabel = { start_time: 'Latest', @@ -480,31 +475,11 @@ export function AddToCollectionModal({ shortId }: { shortId?: string }): JSX.Ele /** Bulk "Scan these recordings" row whose scanner list opens on hover (a nested `items` menu is click-only). */ export function BulkScanMenuItem(): JSX.Element { - const { selectedRecordingsIds } = useValues(sessionRecordingsPlaylistLogic) - const { scanners, scannersLoading, scanning } = useValues(bulkScanLogic) - const { scanRecordings } = useActions(bulkScanLogic) - const { quota } = useValues(visionQuotaLogic) - const { disabledReason: quotaDisabledReason, tooltip: quotaTooltip } = quotaUx(quota) - - const submenuItems: LemonMenuItem[] = scannersLoading - ? [{ label: 'Loading scanners…', disabledReason: 'Loading' }] - : scanners.length === 0 - ? [ - { - label: 'No scanners yet — create one', - onClick: () => router.actions.push(urls.replayVision()), - 'data-attr': 'vision-bulk-scan-create-scanner', - }, - ] - : scanners.map((scanner) => ({ - label: scanner.name, - onClick: () => scanRecordings(scanner.id, selectedRecordingsIds), - 'data-attr': 'vision-bulk-scan-scanner-item', - })) + const { items, disabledReason, tooltip } = useBulkScanMenuItems() return ( } sideIcon={} - disabledReason={ - scanning - ? 'Starting scans…' - : selectedRecordingsIds.length === 0 - ? 'Select recordings to scan' - : quotaDisabledReason - } - tooltip={quotaTooltip} + disabledReason={disabledReason} + tooltip={tooltip} data-attr="vision-bulk-scan-recordings" > Scan these recordings diff --git a/frontend/src/scenes/session-recordings/playlist/useBulkScanMenuItems.ts b/frontend/src/scenes/session-recordings/playlist/useBulkScanMenuItems.ts new file mode 100644 index 000000000000..fd34f2836378 --- /dev/null +++ b/frontend/src/scenes/session-recordings/playlist/useBulkScanMenuItems.ts @@ -0,0 +1,49 @@ +import { useActions, useValues } from 'kea' +import { router } from 'kea-router' + +import { LemonMenuItem } from 'lib/lemon-ui/LemonMenu/LemonMenu' +import { urls } from 'scenes/urls' + +import { bulkScanLogic } from 'products/replay_vision/frontend/logics/bulkScanLogic' +import { visionQuotaLogic } from 'products/replay_vision/frontend/logics/visionQuotaLogic' +import { quotaUx } from 'products/replay_vision/frontend/utils/quotaProjection' + +import { sessionRecordingsPlaylistLogic } from './sessionRecordingsPlaylistLogic' + +export function useBulkScanMenuItems(): { + items: LemonMenuItem[] + disabledReason: string | undefined + tooltip: string | undefined +} { + const { selectedRecordingsIds } = useValues(sessionRecordingsPlaylistLogic) + const { scanners, scannersLoading, scanning } = useValues(bulkScanLogic) + const { scanRecordings } = useActions(bulkScanLogic) + const { quota } = useValues(visionQuotaLogic) + const { disabledReason: quotaDisabledReason, tooltip: quotaTooltip } = quotaUx(quota) + + const items: LemonMenuItem[] = scannersLoading + ? [{ label: 'Loading scanners…', disabledReason: 'Loading' }] + : scanners.length === 0 + ? [ + { + label: 'No scanners yet — create one', + onClick: () => router.actions.push(urls.replayVision()), + 'data-attr': 'vision-bulk-scan-create-scanner', + }, + ] + : scanners.map((scanner) => ({ + label: scanner.name, + onClick: () => scanRecordings(scanner.id, selectedRecordingsIds), + 'data-attr': 'vision-bulk-scan-scanner-item', + })) + + return { + items, + disabledReason: scanning + ? 'Starting scans…' + : selectedRecordingsIds.length === 0 + ? 'Select recordings to scan' + : quotaDisabledReason, + tooltip: quotaTooltip, + } +}