Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -29,22 +29,21 @@ export function OngoingRecordingsGroup({
const expanded = choice ?? (holdsActive || defaultOpen)

return (
<div className="border-b">
<LemonButton
fullWidth
size="xsmall"
className="rounded-none bg-surface-secondary"
icon={<IconLive className="text-primary-3000" />}
sideIcon={expanded ? <IconChevronDown /> : <IconChevronRight />}
onClick={() => setChoice(!expanded)}
data-attr="list-ongoing-toggle"
>
<span className="flex items-center gap-1 text-[11px] uppercase tracking-wide text-secondary font-semibold">
<Collapsible
data-quill
open={expanded}
onOpenChange={(open) => setChoice(open)}
className="border-b border-border"
>
<CollapsibleTrigger className="w-full rounded-none bg-muted" data-attr="list-ongoing-toggle">
<IconLive className="text-primary-3000" />
<span className="flex items-center gap-1 text-[11px] uppercase tracking-wide text-muted-foreground font-semibold">
Still recording
<LemonBadge.Number count={count} status="muted" size="small" />
<Badge>{count}</Badge>
</span>
</LemonButton>
{expanded && children}
</div>
<span className="grow" />
</CollapsibleTrigger>
<CollapsibleContent>{children}</CollapsibleContent>
</Collapsible>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@
watchLayout?: boolean
}

export function Playlist({

Check warning on line 84 in frontend/src/scenes/session-recordings/playlist/Playlist.tsx

View workflow job for this annotation

GitHub Actions / Frontend formatting

lint:complexity

`Playlist` has cyclomatic complexity 38 (warn >10)
title,
type,
logicKey,
Expand Down Expand Up @@ -386,7 +386,7 @@
<ReplayListHeader
filters={filters}
setFilters={setFilters}
collapseButton={collapseButton}
onCollapse={() => setPlaylistCollapsed(true)}
type={type}
shortId={type === 'collection' ? logicKey : undefined}
/>
Expand Down
199 changes: 199 additions & 0 deletions frontend/src/scenes/session-recordings/playlist/QuillMenuItems.tsx
Original file line number Diff line number Diff line change
@@ -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<LemonMenuItems[number], false | null>
type MenuEntry = Exclude<LemonMenuItem, LemonMenuItemCustom>
type MenuLeaf = Exclude<MenuEntry, LemonMenuItemNode>

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) => (
<Fragment key={section.key ?? index}>
{index > 0 && <DropdownMenuSeparator />}
<DropdownMenuGroup>
{section.title && <DropdownMenuLabel>{section.title}</DropdownMenuLabel>}
<SectionItems
items={section.items.filter((item): item is LemonMenuItem => !!item)}
closeOnClick={closeOnClick}
/>
{section.footer}
</DropdownMenuGroup>
</Fragment>
))}
</>
)
}

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) => (
<Entry key={itemKey(item, index)} item={item} closeOnClick={closeOnClick} />
))}
</>
)
}
const activeIndex = items.findIndex((item) => 'active' in item && item.active)
return (
<DropdownMenuRadioGroup value={activeIndex >= 0 ? itemKey(items[activeIndex], activeIndex) : ''}>
{items.map((item, index) => (
<Entry
key={itemKey(item, index)}
item={item}
closeOnClick={closeOnClick}
radioValue={itemKey(item, index)}
/>
))}
</DropdownMenuRadioGroup>
)
}

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 (
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={entry.disabled}
disabledReason={entry.disabledReason}
tooltip={tooltipText(entry.tooltip)}
data-attr={entry['data-attr']}
>
{content}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<QuillMenuItems items={entry.items} closeOnClick={closeOnClick} />
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
const leaf: MenuLeaf = entry
if (radioValue !== undefined) {
return (
<DropdownMenuRadioItem
value={radioValue}
onClick={leaf.onClick}
closeOnClick={closeOnClick}
disabled={entry.disabled}
disabledReason={entry.disabledReason}
tooltip={tooltipText(entry.tooltip)}
data-attr={entry['data-attr']}
>
{content}
</DropdownMenuRadioItem>
)
}
return (
<DropdownMenuItem
onClick={leaf.onClick}
closeOnClick={closeOnClick}
variant={leaf.status === 'danger' ? 'destructive' : 'default'}
disabled={entry.disabled}
disabledReason={entry.disabledReason}
tooltip={tooltipText(entry.tooltip)}
data-attr={entry['data-attr']}
{...('to' in entry
? {
render: (
<LinkPrimitive
to={entry.to}
target={entry.targetBlank ? '_blank' : undefined}
disableClientSideRouting={entry.disableClientSideRouting}
/>
),
}
: {})}
>
{content}
</DropdownMenuItem>
)
}
63 changes: 38 additions & 25 deletions frontend/src/scenes/session-recordings/playlist/ReplayBulkBar.tsx
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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
Expand All @@ -40,7 +43,14 @@ export function ReplayBulkBar({ type, shortId }: { type: 'filters' | 'collection
},
]
: []),
{ key: 'bulk-scan-recordings', label: () => <BulkScanMenuItem />, custom: true },
{
label: 'Scan these recordings',
icon: <IconEye />,
items: bulkScan.items,
disabledReason: bulkScan.disabledReason,
tooltip: bulkScan.tooltip,
'data-attr': 'vision-bulk-scan-recordings',
},
]

const primaryItems: LemonMenuItemLeafCallback[] = [
Expand All @@ -65,35 +75,38 @@ export function ReplayBulkBar({ type, shortId }: { type: 'filters' | 'collection
<span className="font-semibold px-1 shrink-0">{selectedRecordingsIds.length} selected</span>
<div className="hidden @min-[24rem]/list-header:flex items-center gap-1">
{primaryItems.map((item) => (
<LemonButton
<Button
key={item['data-attr']}
size="xsmall"
status={item.status}
variant={item.status === 'danger' ? 'destructive-outline' : 'default'}
onClick={item.onClick}
disabledReason={item.disabledReason}
data-attr={item['data-attr']}
>
{item.label}
</LemonButton>
</Button>
))}
<LemonMenu items={moreItems} placement="bottom-end" buttonSize="xsmall">
<LemonButton
size="xsmall"
icon={<IconEllipsis />}
aria-label="More actions"
data-attr="bulk-action-menu"
/>
</LemonMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button size="icon" aria-label="More actions" data-attr="bulk-action-menu" />}
>
<IconEllipsis />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<QuillMenuItems items={moreItems} />
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="@min-[24rem]/list-header:hidden">
<LemonMenu items={[...primaryItems, ...moreItems]} placement="bottom-end" buttonSize="xsmall">
<LemonButton
size="xsmall"
icon={<IconEllipsis />}
aria-label="Bulk actions"
data-attr="bulk-action-menu-narrow"
/>
</LemonMenu>
<DropdownMenu>
<DropdownMenuTrigger
render={<Button size="icon" aria-label="Bulk actions" data-attr="bulk-action-menu-narrow" />}
>
<IconEllipsis />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<QuillMenuItems items={[...primaryItems, ...moreItems]} />
</DropdownMenuContent>
</DropdownMenu>
</div>
<ConfirmDeleteRecordings shortId={shortId} />
<AddToCollectionModal shortId={shortId} />
Expand Down
Loading
Loading