Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Playlist component #23048

Merged
merged 38 commits into from
Jun 20, 2024
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
2e1aae5
chore: rename existing recordings logic
daibhin Jun 17, 2024
858ddb8
Merge branch 'master' into dn-chore/rename-recordings-playlist-logic
daibhin Jun 17, 2024
f8f2727
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 17, 2024
262eac1
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 17, 2024
3b1ea82
Merge branch 'dn-chore/rename-recordings-playlist-logic' of https://g…
daibhin Jun 17, 2024
0de89e2
fix import
daibhin Jun 17, 2024
70d7010
start abstracting Playlist component
daibhin Jun 17, 2024
02f79b3
fix linting errors
daibhin Jun 18, 2024
510530d
move styles
daibhin Jun 18, 2024
285e93b
lemon collapse sections & header actions
daibhin Jun 18, 2024
01240a4
remove recording references
daibhin Jun 18, 2024
9955330
rename classes
daibhin Jun 18, 2024
f43afc9
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 18, 2024
a851ea1
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 18, 2024
9f4e764
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 18, 2024
dc418cd
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 18, 2024
0f16248
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 18, 2024
acbba2b
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 18, 2024
bc6b2da
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 18, 2024
f22ecbb
Merge branch 'master' into dn-chore/split-playlist-loading
daibhin Jun 19, 2024
504b1d6
fix styles
daibhin Jun 19, 2024
06a4057
add story
daibhin Jun 19, 2024
5471ecd
playlist stories
daibhin Jun 19, 2024
1a5d446
type the playlist properly
daibhin Jun 19, 2024
e2280fb
initially open sections
daibhin Jun 19, 2024
c50f669
reset snapshots
daibhin Jun 19, 2024
e1b56a5
revert some unnecessary changes
daibhin Jun 19, 2024
b2b4d53
fix stories
daibhin Jun 19, 2024
d3509f4
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 19, 2024
a47d2ed
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 19, 2024
321dd36
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 19, 2024
6226442
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 19, 2024
205778b
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 19, 2024
edf521e
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 19, 2024
5fe2b03
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 19, 2024
bc0e993
Update UI snapshots for `chromium` (1)
github-actions[bot] Jun 19, 2024
59f1674
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 19, 2024
ab270e7
Update UI snapshots for `chromium` (2)
github-actions[bot] Jun 19, 2024
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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
@import '../../../styles/mixins';
@import '../../../styles/vars';

.SessionRecordingsPlaylist {
.Playlist {
display: flex;
flex-direction: row;
align-items: flex-start;
Expand All @@ -11,15 +11,15 @@
border: 1px solid var(--border);
border-radius: var(--radius);

.SessionRecordingsPlaylist__list {
.Playlist__list {
position: relative;
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
overflow: hidden;

&:not(.SessionRecordingsPlaylist__list--collapsed) {
&:not(.Playlist__list--collapsed) {
width: 25%;
min-width: 305px;
max-width: 350px;
Expand All @@ -30,26 +30,19 @@
}
}

.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 {
border: none;
}

&--wide {
.SessionRecordingsPlaylist__player {
.Playlist__main {
flex: 1;
height: 100%;
}
Expand Down
305 changes: 305 additions & 0 deletions frontend/src/lib/components/Playlist/Playlist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
import './Playlist.scss'

import { IconCollapse } from '@posthog/icons'
import { LemonButton, LemonButtonProps, LemonCollapse, LemonSkeleton, Spinner, 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: any[]
render: ({ item, isActive }: { item: any; isActive: any }) => JSX.Element
}

type PlaylistHeaderAction = Pick<LemonButtonProps, 'icon' | 'tooltip' | 'children'> & {
key: string
content: React.ReactNode
}

type PlaylistProps = {
title?: string
notebooksHref?: string
embedded: boolean
sections: PlaylistSection[]
loading?: boolean
headerActions?: PlaylistHeaderAction[]
onScrollListEdge?: (edge: 'top' | 'bottom') => void
listEmptyState: JSX.Element
onSelect: (item: any) => void
content: ({ activeItem }: { activeItem: any }) => JSX.Element
onLoadMore?: () => void
'data-attr'?: string
activeItemId?: string
}

const CounterBadge = ({ children }: { children: React.ReactNode }): JSX.Element => (
<span className="rounded py-1 px-2 mr-1 text-xs bg-border-light font-semibold select-none">{children}</span>
)

export function Playlist({
title,
notebooksHref,
loading,
embedded,
activeItemId: propsActiveItemId,
content,
sections,
headerActions = [],
onScrollListEdge,
listEmptyState,
onSelect,
'data-attr': dataAttr,
onLoadMore,
}: PlaylistProps): JSX.Element {
const [controlledActiveItemId, setControlledActiveItemId] = useState<string | null>(null)
const [listCollapsed, setListCollapsed] = useState<boolean>(false)
const playlistListRef = useRef<HTMLDivElement>(null)
const { ref: playlistRef, size } = useResizeBreakpoints({
0: 'small',
750: 'medium',
})

const onChangeActiveItem = (item: any): void => {
setControlledActiveItemId(item.id)
onSelect(item.id)
}

const activeItemId = propsActiveItemId === undefined ? controlledActiveItemId : propsActiveItemId

const activeItem = sections.flatMap((s) => s.items).find((i) => i.id === activeItemId)

return (
<div
ref={playlistRef}
data-attr={dataAttr}
className={clsx('Playlist', {
'Playlist--wide': size !== 'small',
'Playlist--embedded': embedded,
})}
>
<div ref={playlistListRef} className={clsx('Playlist__list', listCollapsed && 'Playlist__list--collapsed')}>
{listCollapsed ? (
<CollapsedList onClickOpen={() => setListCollapsed(false)} />
) : (
<List
title={title}
notebooksHref={notebooksHref}
loading={loading}
sections={sections}
headerActions={headerActions}
onScrollListEdge={onScrollListEdge}
onClickCollapse={() => setListCollapsed(true)}
activeItemId={activeItemId}
setActiveItemId={onChangeActiveItem}
emptyState={listEmptyState}
onLoadMore={onLoadMore}
/>
)}
<Resizer
logicKey="playlist-list"
placement="right"
containerRef={playlistListRef}
closeThreshold={100}
onToggleClosed={(value) => setListCollapsed(value)}
onDoubleClick={() => setListCollapsed(!listCollapsed)}
/>
</div>
<div className="Playlist__main">{content({ activeItem })}</div>
</div>
)
}

const CollapsedList = ({ onClickOpen }: { onClickOpen: () => void }): JSX.Element => (
<div className="flex items-start h-full bg-bg-light border-r p-1">
<LemonButton size="small" icon={<IconChevronRight />} onClick={onClickOpen} />
</div>
)

const List = ({
title,
notebooksHref,
onClickCollapse,
setActiveItemId,
headerActions = [],
sections,
activeItemId,
onScrollListEdge,
loading,
emptyState,
onLoadMore,
}: {
title: PlaylistProps['title']
notebooksHref: PlaylistProps['notebooksHref']
onClickCollapse: () => void
activeItemId: string | null
setActiveItemId: (id: string) => void
headerActions: PlaylistProps['headerActions']
sections: PlaylistProps['sections']
onScrollListEdge: PlaylistProps['onScrollListEdge']
loading: PlaylistProps['loading']
emptyState: PlaylistProps['listEmptyState']
onLoadMore: PlaylistProps['onLoadMore']
}): JSX.Element => {
const [activeHeaderActionKey, setActiveHeaderActionKey] = useState<string | null>(null)
const lastScrollPositionRef = useRef(0)
const contentRef = useRef<HTMLDivElement | null>(null)

useEffect(() => {
if (contentRef.current) {
contentRef.current.scrollTop = 0
}
}, [activeHeaderActionKey])

const handleScroll = (e: React.UIEvent<HTMLDivElement>): 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 actionContent = headerActions?.find((a) => activeHeaderActionKey === a.key)?.content

const itemsCount = sections.flatMap((s) => s.items).length

return (
<div className="flex flex-col w-full bg-bg-light overflow-hidden border-r h-full">
<DraggableToNotebook href={notebooksHref}>
<div className="shrink-0 relative flex justify-between items-center p-1 gap-1 whitespace-nowrap border-b">
<LemonButton size="small" icon={<IconCollapse className="rotate-90" />} onClick={onClickCollapse} />
<span className="py-1 flex flex-1 gap-2">
{title ? (
<span className="font-bold uppercase text-xs my-1 tracking-wide flex gap-1 items-center">
{title}
</span>
) : null}
<Tooltip
placement="bottom"
title={
<>
Showing {itemsCount} results.
<br />
Scrolling to the bottom or the top of the list will load older or newer results
respectively.
</>
}
>
<span>
<CounterBadge>{Math.min(999, itemsCount)}+</CounterBadge>
</span>
</Tooltip>
</span>
{headerActions.map(({ key, icon, tooltip, children }) => (
<LemonButton
key={key}
icon={icon}
tooltip={tooltip}
size="small"
active={activeHeaderActionKey === key}
onClick={() => setActiveHeaderActionKey(activeHeaderActionKey === key ? null : key)}
>
{children}
</LemonButton>
))}
<LemonTableLoader loading={loading} />
</div>
</DraggableToNotebook>

<div className={clsx('overflow-y-auto')} onScroll={handleScroll} ref={contentRef}>
{actionContent && <div className="bg-side">{actionContent}</div>}

{sections.flatMap((s) => s.items).length ? (
<>
{sections.length > 1 ? (
<LemonCollapse
defaultActiveKeys={sections.map((s) => s.key)}
panels={sections.map((s) => ({
key: s.key,
header: s.title,
content: (
<ListSection {...s} activeItemId={activeItemId} onClick={setActiveItemId} />
),
className: 'p-0',
}))}
embedded
/>
) : (
<ListSection {...sections[0]} activeItemId={activeItemId} onClick={setActiveItemId} />
)}

<div className="m-4 h-10 flex items-center justify-center gap-2 text-muted-alt">
{loading ? (
<>
<Spinner textColored /> Loading
</>
) : onLoadMore ? (
<LemonButton onClick={onLoadMore}>Load more</LemonButton>
) : (
'No more results'
)}
</div>
</>
) : loading ? (
<LoadingState />
) : (
emptyState
)}
</div>
</div>
)
}

const ListSection = ({
items,
render,
onClick,
activeItemId,
}: PlaylistSection & {
onClick: (item: any) => void
activeItemId: string | null
}): JSX.Element => {
return (
<>
{items.length &&
items.map((item) => (
<div key={item.id} className="border-b" onClick={() => onClick(item)}>
{render({ item, isActive: item.id === activeItemId })}
</div>
))}
</>
)
}

const LoadingState = (): JSX.Element => {
return (
<>
{range(20).map((i) => (
<div key={i} className="p-4 space-y-2">
<LemonSkeleton className="w-1/2 h-4" />
<LemonSkeleton className="w-1/3 h-4" />
</div>
))}
</>
)
}
5 changes: 5 additions & 0 deletions frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.scss
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius);

&--embedded {
border: none;
border-radius: 0;
}
}

.LemonCollapsePanel {
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/lemon-ui/LemonCollapse/LemonCollapse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ interface LemonCollapsePropsBase<K extends React.Key> {
panels: (LemonCollapsePanel<K> | null | false)[]
className?: string
size?: LemonButtonProps['size']
embedded?: boolean
}

interface LemonCollapsePropsSingle<K extends React.Key> extends LemonCollapsePropsBase<K> {
Expand All @@ -43,6 +44,7 @@ export function LemonCollapse<K extends React.Key>({
panels,
className,
size,
embedded,
...props
}: LemonCollapseProps<K>): JSX.Element {
let isPanelExpanded: (key: K) => boolean
Expand Down Expand Up @@ -72,7 +74,7 @@ export function LemonCollapse<K extends React.Key>({
}

return (
<div className={clsx('LemonCollapse', className)}>
<div className={clsx('LemonCollapse', embedded && 'LemonCollapse--embedded', className)}>
{(panels.filter(Boolean) as LemonCollapsePanel<K>[]).map(({ key, ...panel }) => (
<LemonCollapsePanel
key={key}
Expand Down
Loading
Loading