0
0
mirror of https://github.com/PostHog/posthog.git synced 2024-11-21 13:39:22 +01:00

Fresh table pilot (#7103)

* Add `LemonTable` base to Feature flags page

* Fix basic styling

* Rework more of the table

* Fix column `align`

* Update eventsListLogic.ts

* Align FF table columns with design

* Add pagination

* Add table loader

* Add `LemonTable` to Storybook

* Add sorting

* Increase feature flags page size to 50

* Add scroll indication

* Fix minor issues

* Fix typing

* Update E2E test

* Sort one column at a time

* Add default sorting

* Improve current page handling

* Use search params for current page state

* Don't mute disabled feature flags

* Add overlay for loading state

* Add profile picture to Created by and improve comments

* Fix `createdByColumn`

* Refactor the More button for reusability

* Fix content sizing (pagination/loader filling full width when scrolled)

* Remove need for `@ts-expect-error`
This commit is contained in:
Michael Matloka 2021-11-19 17:50:43 +01:00 committed by GitHub
parent b4abf84424
commit 2f1a6af0ef
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 757 additions and 203 deletions

View File

@ -38,7 +38,7 @@ describe('Feature Flags', () => {
cy.get('[data-attr=feature-flag-table]').should('not.contain', '%') // By default it's released to everyone, if a % is not specified
cy.get('[data-attr=feature-flag-table]').should('contain', 'is_demo')
cy.get(`[data-row-key=${name}]`).click()
cy.get(`[data-row-key=${name}]`).contains(name).click()
cy.get('[data-attr=feature-flag-key]')
.type('-updated')
.should('have.value', name + '-updated')
@ -46,7 +46,8 @@ describe('Feature Flags', () => {
cy.get('.Toastify__toast-body').click() // clicking the toast gets you back to the list
cy.get('[data-attr=feature-flag-table]').should('contain', name + '-updated')
cy.get(`[data-row-key=${name}-updated] [data-attr=usage]`).click()
cy.get(`[data-row-key=${name}-updated] [data-attr=more-button]`).click()
cy.contains(`Use in Insights`).click()
cy.location().should((loc) => {
expect(loc.pathname.toString()).to.contain('/insight')
})
@ -60,7 +61,7 @@ describe('Feature Flags', () => {
cy.get('[data-attr=feature-flag-submit]').click()
cy.get('.Toastify__toast-body').click() // clicking the toast gets you back to the list
cy.get('[data-attr=feature-flag-table]').should('contain', name)
cy.get('[data-row-key="' + name + '"]').click()
cy.get(`[data-row-key=${name}]`).contains(name).click()
cy.get('[data-attr=delete-flag]').click()
cy.contains('Click to undo').should('exist')
})

View File

@ -46,5 +46,4 @@
margin: 0 0.5rem;
font-size: 1rem;
color: var(--primary-alt);
transform: rotate(-90deg);
}

View File

@ -1,6 +1,6 @@
import React, { useState } from 'react'
import { useValues } from 'kea'
import { IconExpandMore, IconArrowDropDown } from 'lib/components/icons'
import { IconArrowDropDown, IconChevronRight } from 'lib/components/icons'
import { Link } from 'lib/components/Link'
import './Breadcrumbs.scss'
import { Breadcrumb as IBreadcrumb, breadcrumbsLogic } from './breadcrumbsLogic'
@ -52,7 +52,7 @@ export function Breadcrumbs(): JSX.Element | null {
<Breadcrumb breadcrumb={breadcrumbs[0]} />
{breadcrumbs.slice(1).map((breadcrumb) => (
<React.Fragment key={breadcrumb.name || '…'}>
<IconExpandMore className="Breadcrumbs__separator" />
<IconChevronRight className="Breadcrumbs__separator" />
<Breadcrumb breadcrumb={breadcrumb} />
</React.Fragment>
))}

View File

@ -35,7 +35,7 @@ import { ToolbarModal } from '../../ToolbarModal/ToolbarModal'
import { navigationLogic } from '../navigationLogic'
import './SideBar.scss'
function SidebarProjectSwitcher(): JSX.Element {
function ProjectSwitcherInternal(): JSX.Element {
const { currentTeam } = useValues(teamLogic)
const { currentOrganization } = useValues(organizationLogic)
const { isProjectSwitcherShown } = useValues(navigationLogic)
@ -203,7 +203,7 @@ export function SideBar({ children }: { children: React.ReactNode }): JSX.Elemen
<div className={clsx('SideBar', 'SideBar__layout', !isSideBarShown && 'SideBar--hidden')}>
<div className="SideBar__slider">
<div className="SideBar__content">
<SidebarProjectSwitcher />
<ProjectSwitcherInternal />
{currentTeam && (
<>
<LemonSpacer />

View File

@ -3,6 +3,7 @@ import { Input } from 'antd'
import { CopyOutlined } from '@ant-design/icons'
import { copyToClipboard } from 'lib/utils'
import { Tooltip } from 'lib/components/Tooltip'
import { IconCopy } from './icons'
interface InlineProps extends HTMLProps<HTMLSpanElement> {
children?: JSX.Element | string
@ -41,7 +42,7 @@ export function CopyToClipboardInline({
display: 'flex',
alignItems: 'center',
flexDirection: iconPosition === 'end' ? 'row' : 'row-reverse',
flexWrap: iconPosition === 'end' ? 'wrap' : 'wrap-reverse',
flexWrap: 'nowrap',
...style,
}}
onClick={() => {
@ -50,8 +51,13 @@ export function CopyToClipboardInline({
{...props}
>
<span style={iconPosition === 'start' ? { flexGrow: 1 } : {}}>{children}</span>
<CopyOutlined
style={iconPosition === 'end' ? { marginLeft: 4, ...iconStyle } : { marginRight: 4, ...iconStyle }}
<IconCopy
style={{
[iconPosition === 'end' ? 'marginLeft' : 'marginRight']: 4,
color: 'var(--primary)',
flexShrink: 0,
...iconStyle,
}}
/>
</span>
)

View File

@ -11,7 +11,7 @@
background: var(--primary-bg-active);
}
&:disabled {
opacity: 0.7;
opacity: 0.6;
cursor: not-allowed;
}
.LemonRow__icon {

View File

@ -0,0 +1,29 @@
import React, { useState } from 'react'
import { LemonButton } from '.'
import { IconEllipsis } from '../icons'
import { PopupProps } from '../Popup/Popup'
export function More({ overlay }: Pick<PopupProps, 'overlay'>): JSX.Element {
const [visible, setVisible] = useState(false)
return (
<LemonButton
compact
data-attr="more-button"
icon={<IconEllipsis />}
type="stealth"
onClick={(e) => {
setVisible((state) => !state)
e.stopPropagation()
}}
popup={{
visible,
onClickOutside: () => setVisible(false),
onClickInside: () => setVisible(false),
placement: 'bottom-end',
actionable: true,
overlay,
}}
/>
)
}

View File

@ -83,7 +83,7 @@
display: flex;
align-items: center;
&:not(:first-child) {
padding-left: 0.5rem;
margin-left: 0.5rem;
}
}

View File

@ -22,6 +22,9 @@
background-color: var(--border);
transition: background-color 100ms ease;
.LemonSwitch--checked & {
background-color: rgba($primary, 0.25);
}
.LemonSwitch--alt.LemonSwitch--checked & {
background-color: rgba($primary_alt, 0.25);
}
}
@ -49,6 +52,9 @@
border-left-color: transparent;
}
.LemonSwitch--loading &::after {
border-left-color: var(--primary);
}
.LemonSwitch--alt.LemonSwitch--loading &::after {
border-left-color: var(--primary-alt);
}
.LemonSwitch--loading.LemonSwitch--checked &::after {
@ -56,6 +62,10 @@
}
.LemonSwitch--checked & {
transform: translateX(1rem);
background-color: var(--primary);
border-color: var(--primary);
}
.LemonSwitch--alt.LemonSwitch--checked & {
background-color: var(--primary-alt);
border-color: var(--primary-alt);
}

View File

@ -7,9 +7,12 @@ export interface LemonSwitchProps {
onChange: (newChecked: boolean) => void
checked: boolean
loading?: boolean
/** Whether the switch should use the alternative primary color. */
alt?: boolean
style?: React.CSSProperties
}
export function LemonSwitch({ id, onChange, checked, loading }: LemonSwitchProps): JSX.Element {
export function LemonSwitch({ id, onChange, checked, loading, alt, style }: LemonSwitchProps): JSX.Element {
const [isActive, setIsActive] = useState(false)
return (
@ -21,12 +24,14 @@ export function LemonSwitch({ id, onChange, checked, loading }: LemonSwitchProps
'LemonSwitch',
checked && 'LemonSwitch--checked',
isActive && 'LemonSwitch--active',
loading && 'LemonSwitch--loading'
loading && 'LemonSwitch--loading',
alt && 'LemonSwitch--alt'
)}
onClick={() => onChange(!checked)}
onMouseDown={() => setIsActive(true)}
onMouseUp={() => setIsActive(false)}
onMouseOut={() => setIsActive(false)}
style={style}
>
<div className="LemonSwitch__slider" />
<div className="LemonSwitch__handle" />

View File

@ -0,0 +1,159 @@
.LemonTable {
position: relative;
width: 100%;
background: #fff;
border-radius: var(--radius);
border: 1px solid var(--border);
overflow: hidden;
&::before,
&::after {
transition: box-shadow 200ms ease;
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
&::before {
box-shadow: 16px 0 16px -32px rgba(0, 0, 0, 0.25) inset;
}
&.LemonTable--scrollable-left::before {
box-shadow: 16px 0 16px -16px rgba(0, 0, 0, 0.25) inset;
}
&::after {
box-shadow: -16px 0 16px -32px rgba(0, 0, 0, 0.25) inset;
}
&.LemonTable--scrollable-right::after {
box-shadow: -16px 0 16px -16px rgba(0, 0, 0, 0.25) inset;
}
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
thead {
background: var(--bg-mid);
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.03125rem;
text-transform: uppercase;
}
tbody {
tr {
border-top: 1px solid var(--border);
}
td {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
}
tr {
height: 3rem;
}
th,
td {
padding-left: 1rem;
padding-right: 1rem;
}
h4.row-name {
font-size: 0.875rem;
font-weight: 600;
color: inherit;
margin-bottom: 0.125rem;
}
span.row-description {
font-size: 0.75rem;
}
}
.LemonTable__scroll {
width: 100%;
overflow: auto hidden;
}
.LemonTable__content {
min-width: fit-content;
}
.LemonTable__overlay {
transition: opacity 200ms ease;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(255, 255, 255, 0.5);
opacity: 0;
pointer-events: none;
.LemonTable--loading & {
opacity: 1;
pointer-events: auto;
}
}
.LemonTable__loader {
transition: height 200ms ease, top 200ms ease;
position: absolute;
left: 0;
top: 3rem;
width: 100%;
height: 0;
background: var(--primary-bg-active);
overflow: hidden;
&::after {
content: '';
position: absolute;
left: 0;
top: 0;
width: 50%;
height: 100%;
animation: loading-bar 1.5s linear infinite;
background: var(--primary);
}
.LemonTable--loading & {
top: 2.75rem;
height: 0.25rem;
}
}
.LemonTable__pagination {
display: flex;
align-items: center;
justify-content: end;
height: 3rem;
border-top: 1px solid var(--border);
padding: 0 1rem;
> span {
margin-right: 0.5rem;
}
}
.LemonTable__header--actionable {
cursor: pointer;
}
.LemonTable__header-content {
display: flex;
align-items: center;
justify-content: space-between;
}
@keyframes loading-bar {
0% {
left: 0;
width: 33.333%;
transform: translateX(-100%);
}
50% {
width: 50%;
}
100% {
left: 100%;
width: 33.333%;
transform: translateX(100%);
}
}

View File

@ -0,0 +1,28 @@
import React from 'react'
import { ComponentMeta } from '@storybook/react'
import { LemonTable as _LemonTable } from './LemonTable'
export default {
title: 'PostHog/Components/LemonTable',
component: _LemonTable,
parameters: { options: { showPanel: true } },
argTypes: {
loading: {
control: {
type: 'boolean',
},
},
},
} as ComponentMeta<typeof _LemonTable>
export function LemonTable({ loading }: { loading: boolean }): JSX.Element {
return (
<_LemonTable
loading={loading}
columns={[{ title: 'Column' }]}
dataSource={[] as Record<string, any>[]}
pagination={{ pageSize: 10 }}
/>
)
}

View File

@ -0,0 +1,255 @@
import clsx from 'clsx'
import { useActions, useValues } from 'kea'
import { router } from 'kea-router'
import React, { HTMLProps, Reducer, useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react'
import { columnSort } from '../../../scenes/saved-insights/SavedInsights'
import { useResizeObserver } from '../../hooks/useResizeObserver'
import { IconChevronLeft, IconChevronRight } from '../icons'
import { LemonButton } from '../LemonButton'
import './LemonTable.scss'
/** 1 means ascending, -1 means descending. */
export type SortOrder = 1 | -1
/** Sorting state. */
export interface Sorting {
columnIndex: number
order: SortOrder
}
export interface LemonTableColumn<T extends Record<string, any>, D extends keyof T> {
title?: string | React.ReactNode
key?: keyof T
dataIndex?: D
render?: (dataValue: T[D] | undefined, record: T) => React.ReactNode | string | boolean | null | undefined
sorter?: (a: T, b: T) => number
className?: string
/** Column content alignment. Left by default. Set to right for numerical values (amounts, days ago etc.) */
align?: 'left' | 'right' | 'center'
/** TODO: Set colspan */
span?: number
/** TODO: Whether the column should be sticky when scrolling */
sticky?: boolean
/** TODO: Set width */
width?: string | number
}
export type LemonTableColumns<T extends Record<string, any>> = LemonTableColumn<T, keyof T>[]
export interface LemonTableProps<T extends Record<string, any>> {
/** Element key that will also be used in pagination to improve search param uniqueness. */
key?: string
columns: LemonTableColumns<T>
dataSource: T[]
/** Which column to use for the row key, as an alternative to the default row index mechanism. */
rowKey?: keyof T
/** Function that for each row determines what props should its `tr` element have based on the row's record. */
onRow?: (record: T) => Omit<HTMLProps<HTMLTableRowElement>, 'key'>
loading?: boolean
pagination?: { pageSize: number; hideOnSinglePage?: boolean }
/** Sorting order to start with. */
defaultSorting?: Sorting
'data-attr'?: string
}
export function LemonTable<T extends Record<string, any>>({
key,
columns,
dataSource,
rowKey,
onRow,
loading,
pagination,
defaultSorting,
...divProps
}: LemonTableProps<T>): JSX.Element {
/** Search param that will be used for storing and syncing the current page */
const currentPageParam = key ? `${key}_page` : 'page'
const { location, searchParams, hashParams } = useValues(router)
const { push } = useActions(router)
// A tuple signaling scrollability, on the left and on the right respectively
const [isScrollable, setIsScrollable] = useState([false, false])
// Sorting state machine
const [sortingState, sortingDispatch] = useReducer<Reducer<Sorting | null, Pick<Sorting, 'columnIndex'>>>(
(state, action) => {
if (!state || state.columnIndex !== action.columnIndex) {
return { columnIndex: action.columnIndex, order: 1 }
} else if (state.order === 1) {
return { columnIndex: action.columnIndex, order: -1 }
} else {
return null
}
},
defaultSorting || null
)
// Push a new browing history item to keep track of the current page
const setCurrentPage = useCallback(
(newPage: number) => push(location.pathname, { ...searchParams, [currentPageParam]: newPage }, hashParams),
[location, searchParams, hashParams, push]
)
const scrollRef = useRef<HTMLDivElement>(null)
/** Number of pages. */
const pageCount = pagination ? Math.ceil(dataSource.length / pagination.pageSize) : 1
/** Page adjusted for `pageCount` possibly having gotten smaller since last page param update. */
const currentPage = Math.min(parseInt(searchParams[currentPageParam]) || 1, pageCount)
/** Whether there's reason to show pagination. */
const showPagination = pageCount > 1 || pagination?.hideOnSinglePage === true
const updateIsScrollable = useCallback(() => {
const element = scrollRef.current
if (element) {
const left = element.scrollLeft > 0
const right =
element.scrollWidth > element.clientWidth &&
element.scrollWidth > element.scrollLeft + element.clientWidth
if (left !== isScrollable[0] || right !== isScrollable[1]) {
setIsScrollable([left, right])
}
}
}, [isScrollable])
useResizeObserver({
ref: scrollRef,
onResize: updateIsScrollable,
})
useEffect(() => {
const element = scrollRef.current
if (element) {
element.addEventListener('scroll', updateIsScrollable)
return () => element.removeEventListener('scroll', updateIsScrollable)
}
}, [updateIsScrollable])
const { currentFrame, currentStartIndex, currentEndIndex } = useMemo(() => {
let processedDataSource = dataSource
if (sortingState) {
const sorter = columns[sortingState.columnIndex].sorter
if (sorter) {
processedDataSource = processedDataSource.slice().sort((a, b) => sortingState.order * sorter(a, b))
}
}
const calculatedStartIndex = pagination ? (currentPage - 1) * pagination.pageSize : 0
const calculatedFrame = pagination
? processedDataSource.slice(calculatedStartIndex, calculatedStartIndex + pagination.pageSize)
: processedDataSource
const calculatedEndIndex = calculatedStartIndex + calculatedFrame.length
return {
currentFrame: calculatedFrame,
currentStartIndex: calculatedStartIndex,
currentEndIndex: calculatedEndIndex,
}
}, [currentPage, pageCount, pagination, dataSource, sortingState])
return (
<div
className={clsx(
'LemonTable',
loading && 'LemonTable--loading',
showPagination && 'LemonTable--paginated',
isScrollable[0] && 'LemonTable--scrollable-left',
isScrollable[1] && 'LemonTable--scrollable-right'
)}
{...divProps}
>
<div className="LemonTable__scroll" ref={scrollRef}>
<div className="LemonTable__content">
<table>
<thead>
<tr>
{columns.map((column, columnIndex) => (
<th
key={columnIndex}
className={clsx(
column.sorter && 'LemonTable__header--actionable',
column.className
)}
style={{ textAlign: column.align }}
onClick={column.sorter ? () => sortingDispatch({ columnIndex }) : undefined}
>
<div className="LemonTable__header-content">
{column.title}
{column.sorter &&
columnSort(
sortingState && sortingState.columnIndex === columnIndex
? sortingState.order === 1
? 'up'
: 'down'
: 'none'
)}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{dataSource.length ? (
currentFrame.map((data, rowIndex) => (
<tr
key={`LemonTable-row-${rowKey ? data[rowKey] : currentStartIndex + rowIndex}`}
data-row-key={rowKey ? data[rowKey] : rowIndex}
{...onRow?.(data)}
>
{columns.map((column, columnIndex) => {
const value = column.dataIndex ? data[column.dataIndex] : undefined
const contents = column.render ? column.render(value, data) : value
return (
<td
key={
column.key
? data[column.key]
: column.dataIndex
? data[column.dataIndex]
: columnIndex
}
className={column.className}
style={{ textAlign: column.align }}
>
{contents}
</td>
)
})}
</tr>
))
) : (
<tr>
<td colSpan={columns.length}>No data</td>
</tr>
)}
</tbody>
</table>
{showPagination && (
<div className="LemonTable__pagination">
<span className="LemonTable__locator">
{currentFrame.length === 0
? 'No entries'
: currentFrame.length === 1
? `${currentEndIndex} of ${dataSource.length} entries`
: `${currentStartIndex + 1}-${currentEndIndex} of ${dataSource.length} entries`}
</span>
<LemonButton
compact
icon={<IconChevronLeft />}
type="stealth"
disabled={currentPage === 1}
onClick={() => setCurrentPage(Math.max(1, Math.min(pageCount, currentPage) - 1))}
/>
<LemonButton
compact
icon={<IconChevronRight />}
type="stealth"
disabled={currentPage === pageCount}
onClick={() => setCurrentPage(Math.min(pageCount, currentPage + 1))}
/>
</div>
)}
<div className="LemonTable__overlay" />
<div className="LemonTable__loader" />
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import { TZLabel } from '../TimezoneAware'
import { normalizeColumnTitle } from 'lib/components/Table/utils'
import { Row } from 'antd'
import { ProfilePicture } from '../ProfilePicture'
import { LemonTableColumn } from './LemonTable'
import { UserBasicType } from '~/types'
export function createdAtColumn<T extends { created_at: string | null }>(): LemonTableColumn<T, 'created_at'> {
return {
title: normalizeColumnTitle('Created'),
align: 'right',
render: function RenderCreatedAt(_, item) {
return item.created_at ? (
<div style={{ whiteSpace: 'nowrap' }}>
<TZLabel time={item.created_at} />
</div>
) : (
'-'
)
},
sorter: (a, b) => (new Date(a.created_at || 0) > new Date(b.created_at || 0) ? 1 : -1),
}
}
export function createdByColumn<T extends { created_by?: UserBasicType | null }>(): LemonTableColumn<T, 'created_by'> {
return {
title: normalizeColumnTitle('Created by'),
render: function Render(_: any, item: any) {
return (
<Row align="middle" wrap={false}>
{item.created_by && (
<ProfilePicture name={item.created_by.first_name} email={item.created_by.email} size="md" />
)}
<div style={{ maxWidth: 250, width: 'auto', verticalAlign: 'middle', marginLeft: 8 }}>
{item.created_by ? item.created_by.first_name || item.created_by.email : '-'}
</div>
</Row>
)
},
sorter: (a, b) =>
(a.created_by?.first_name || a.created_by?.email || '').localeCompare(
b.created_by?.first_name || b.created_by?.email || ''
),
}
}

View File

@ -3,7 +3,6 @@
.Popup {
z-index: $z_popup;
box-shadow: $shadow_popup;
margin-top: 0.25rem;
background: #fff;
padding: 0.5rem;
border-radius: var(--radius);
@ -20,8 +19,7 @@
color: var(--muted-alt);
text-transform: uppercase;
letter-spacing: 0.5px;
padding-left: 0.5rem;
margin: 0.25rem 0;
margin: 0.25rem 0.5rem;
line-height: 2;
}
}

View File

@ -1,5 +1,5 @@
import './Popup.scss'
import React, { ReactElement, useContext, useEffect, useMemo, useState } from 'react'
import React, { MouseEventHandler, ReactElement, useContext, useEffect, useMemo, useState } from 'react'
import ReactDOM from 'react-dom'
import { usePopper } from 'react-popper'
import { useOutsideClickHandler } from 'lib/hooks/useOutsideClickHandler'
@ -9,6 +9,7 @@ import clsx from 'clsx'
export interface PopupProps {
visible?: boolean
onClickOutside?: (event: Event) => void
onClickInside?: MouseEventHandler<HTMLDivElement>
/** Popover trigger element. */
children: React.ReactChild | ((props: { setRef: (ref: HTMLElement | null) => void }) => JSX.Element)
/** Content of the overlay. */
@ -35,6 +36,7 @@ export function Popup({
overlay,
visible,
onClickOutside,
onClickInside,
placement = 'bottom-start',
fallbackPlacements = ['bottom-end', 'top-start', 'top-end'],
className,
@ -70,6 +72,12 @@ export function Popup({
const modifiers = useMemo<Partial<Modifier<any, any>>[]>(
() => [
{
name: 'offset',
options: {
offset: [0, 4],
},
},
fallbackPlacements
? {
name: 'flip',
@ -118,6 +126,7 @@ export function Popup({
className={clsx('Popup', actionable && 'Popup--actionable', className)}
ref={setPopperElement}
style={styles.popper}
onClick={onClickInside}
{...attributes.popper}
>
<PopupContext.Provider value={popupId}>{overlay}</PopupContext.Provider>

View File

@ -4,11 +4,15 @@ import { useValues } from 'kea'
import { userLogic } from 'scenes/userLogic'
import { TZLabel } from '../TimezoneAware'
import { normalizeColumnTitle } from 'lib/components/Table/utils'
import { ColumnType } from 'antd/lib/table'
import { Row } from 'antd'
import { ProfilePicture } from '../ProfilePicture'
export function createdAtColumn(): Record<string, any> {
export function createdAtColumn<T extends Record<string, any> = Record<string, any>>(): ColumnType<T> {
return {
title: normalizeColumnTitle('Created'),
render: function RenderCreatedAt(_: any, item: Record<string, any>): JSX.Element | undefined | '' {
align: 'right',
render: function RenderCreatedAt(_, item): JSX.Element | undefined | '' {
return (
item.created_at && (
<div style={{ whiteSpace: 'nowrap' }}>
@ -17,24 +21,28 @@ export function createdAtColumn(): Record<string, any> {
)
)
},
sorter: (a: Record<string, any>, b: Record<string, any>) =>
new Date(a.created_at) > new Date(b.created_at) ? 1 : -1,
sorter: (a, b) => (new Date(a.created_at) > new Date(b.created_at) ? 1 : -1),
}
}
export function createdByColumn(items: Record<string, any>[]): Record<string, any> {
export function createdByColumn<T extends Record<string, any> = Record<string, any>>(items: T[]): ColumnType<T> {
const { user } = useValues(userLogic)
return {
title: normalizeColumnTitle('Created by'),
render: function Render(_: any, item: any) {
return (
<div style={{ maxWidth: 250, width: 'auto' }}>
{item.created_by ? item.created_by.first_name || item.created_by.email : '-'}
</div>
<Row align="middle" wrap={false}>
{item.created_by && (
<ProfilePicture name={item.created_by.first_name} email={item.created_by.email} size="md" />
)}
<div style={{ maxWidth: 250, width: 'auto', verticalAlign: 'middle', marginLeft: 8 }}>
{item.created_by ? item.created_by.first_name || item.created_by.email : '-'}
</div>
</Row>
)
},
filters: uniqueBy(
items.map((item: Record<string, any>) => {
items.map((item: T) => {
if (!item.created_by) {
return {
text: '(none)',
@ -57,9 +65,8 @@ export function createdByColumn(items: Record<string, any>[]): Record<string, an
}
return (a.text + '').localeCompare(b.text + '')
}),
onFilter: (value: string, item: Record<string, any>) =>
(value === null && item.created_by === null) || item.created_by?.uuid === value,
sorter: (a: Record<string, any>, b: Record<string, any>) =>
onFilter: (value, item) => (value === null && item.created_by === null) || item.created_by?.uuid === value,
sorter: (a, b) =>
(a.created_by?.first_name || a.created_by?.email || '').localeCompare(
b.created_by?.first_name || b.created_by?.email || ''
),

View File

@ -552,6 +552,30 @@ export function IconUnfoldLess({ style }: { style?: CSSProperties }): JSX.Elemen
)
}
/** Material Design Chevron Left icon. */
export function IconChevronLeft(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M13.9999 6L15.4099 7.41L10.8299 12L15.4099 16.59L13.9999 18L7.99991 12L13.9999 6Z"
fill="currentColor"
/>
</svg>
)
}
/** Material Design Chevron Right icon. */
export function IconChevronRight(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="M10.0001 6L8.59009 7.41L13.1701 12L8.59009 16.59L10.0001 18L16.0001 12L10.0001 6Z"
fill="currentColor"
/>
</svg>
)
}
/** Material Design Add icon. */
export function IconPlus(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
@ -664,6 +688,7 @@ export function IconBarChart(): JSX.Element {
)
}
/** Material Design Bar Speed icon. */
export function IconGauge(): JSX.Element {
return (
<svg fill="none" width="1em" height="1em" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
@ -838,6 +863,30 @@ export function IconExpandMore(props: React.SVGProps<SVGSVGElement>): JSX.Elemen
)
}
/** Material Design More Horiz icon. */
export function IconEllipsis(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="m6 10c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm12 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm-6 0c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z"
fill="currentColor"
/>
</svg>
)
}
/** Material Design Content Copy icon. */
export function IconCopy(props: React.SVGProps<SVGSVGElement>): JSX.Element {
return (
<svg fill="none" height="24" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg" {...props}>
<path
d="m15.4545 3h-9.81814c-.9 0-1.63636.73636-1.63636 1.63636v11.45454h1.63636v-11.45454h9.81814zm2.4546 3.27273h-9.00001c-.9 0-1.63636.73636-1.63636 1.63636v11.45451c0 .9.73636 1.6364 1.63636 1.6364h9.00001c.9 0 1.6364-.7364 1.6364-1.6364v-11.45451c0-.9-.7364-1.63636-1.6364-1.63636zm0 13.09087h-9.00001v-11.45451h9.00001z"
fill="currentColor"
/>
</svg>
)
}
/** Material Design Receipt icon. */
export function IconBill(): JSX.Element {
return (

View File

@ -0,0 +1,9 @@
import ResizeObserver from 'resize-observer-polyfill'
import useResizeObserverImport from 'use-resize-observer'
// Use polyfill only if needed
if (!window.ResizeObserver) {
window.ResizeObserver = ResizeObserver
}
export const useResizeObserver = useResizeObserverImport

View File

@ -749,25 +749,23 @@ export function dateFilterToText(
return name
}
export function copyToClipboard(value: string, description?: string): boolean {
export function copyToClipboard(value: string, description: string = 'text'): boolean {
if (!navigator.clipboard) {
toast.info('Oops! Clipboard capabilities are only available over HTTPS or localhost.')
toast.info('Oops! Clipboard capabilities are only available over HTTPS or on localhost.')
return false
}
const descriptionAdjusted = description
? description.charAt(0).toUpperCase() + description.slice(1).trim() + ' '
: ''
try {
navigator.clipboard.writeText(value)
toast(
<div>
<h1 className="text-success">Copied to clipboard!</h1>
<p>{descriptionAdjusted} has been copied to your clipboard.</p>
<p>{capitalizeFirstLetter(description)} has been copied to your clipboard.</p>
</div>
)
return true
} catch (e) {
toast.error(`Could not copy ${descriptionAdjusted}to clipboard: ${e}`)
toast.error(`Could not copy ${description} to clipboard: ${e}`)
return false
}
}

View File

@ -1,4 +1,3 @@
import React, { useEffect, useRef } from 'react'
import { responsiveMap } from 'antd/lib/_util/responsiveObserve'
import { ANTD_EXPAND_BUTTON_WIDTH } from '../components/ResizableTable'
@ -29,31 +28,3 @@ export function getActiveBreakpointValue(): number {
export function getBreakpoint(breakpointKey: string): number {
return BREAKPOINT_MAP[breakpointKey] || -1
}
interface ResizeObserverProps {
callback: (entries: ResizeObserverEntry[]) => any
element: React.MutableRefObject<any>
}
export function useResizeObserver({ callback, element }: ResizeObserverProps): void {
const observer = useRef<ResizeObserver | null>(null)
useEffect(() => {
unobserve()
observer.current = new ResizeObserver(callback)
observe()
return unobserve
}, [element.current])
function observe(): void {
if (element?.current && observer?.current) {
observer.current.observe(element.current)
}
}
function unobserve(): void {
if (element?.current && observer?.current) {
observer.current.unobserve(element.current)
}
}
}

View File

@ -19,6 +19,7 @@ import { Link } from 'lib/components/Link'
import { PROPERTY_MATCH_TYPE } from 'lib/constants'
import { SceneExport } from 'scenes/sceneTypes'
import { dayjs } from 'lib/dayjs'
import { ColumnType } from 'antd/lib/table'
import { Spinner } from 'lib/components/Spinner/Spinner'
const NEW_COHORT: CohortType = {
@ -83,20 +84,20 @@ export function Cohorts(): JSX.Element {
const { setOpenCohort } = useActions(cohortsUrlLogic)
const [searchTerm, setSearchTerm] = useState(false as string | false)
const columns = [
const columns: ColumnType<CohortType>[] = [
{
title: 'Name',
dataIndex: 'name',
key: 'name',
className: 'ph-no-capture',
sorter: (a: CohortType, b: CohortType) => ('' + a.name).localeCompare(b.name as string),
sorter: (a, b) => ('' + a.name).localeCompare(b.name as string),
},
{
title: 'Users in cohort',
render: function RenderCount(_: any, cohort: CohortType) {
return cohort.count?.toLocaleString()
},
sorter: (a: CohortType, b: CohortType) => (a.count || 0) - (b.count || 0),
sorter: (a, b) => (a.count || 0) - (b.count || 0),
},
createdAtColumn(),
createdByColumn(cohorts),

View File

@ -1,23 +1,25 @@
import React from 'react'
import { useValues, useActions } from 'kea'
import { featureFlagsLogic } from './featureFlagsLogic'
import { Table, Switch, Typography, Input } from 'antd'
import { Input } from 'antd'
import { Link } from 'lib/components/Link'
import { DeleteWithUndo } from 'lib/utils'
import { ExportOutlined, PlusOutlined, DeleteOutlined, EditOutlined, DisconnectOutlined } from '@ant-design/icons'
import { copyToClipboard, deleteWithUndo } from 'lib/utils'
import { PlusOutlined } from '@ant-design/icons'
import { PageHeader } from 'lib/components/PageHeader'
import PropertyFiltersDisplay from 'lib/components/PropertyFilters/components/PropertyFiltersDisplay'
import { createdAtColumn, createdByColumn } from 'lib/components/Table/Table'
import { FeatureFlagGroupType, FeatureFlagType } from '~/types'
import { router } from 'kea-router'
import { LinkButton } from 'lib/components/LinkButton'
import { CopyToClipboardInline } from 'lib/components/CopyToClipboard'
import { normalizeColumnTitle, useIsTableScrolling } from 'lib/components/Table/utils'
import { normalizeColumnTitle } from 'lib/components/Table/utils'
import { urls } from 'scenes/urls'
import { Tooltip } from 'lib/components/Tooltip'
import stringWithWBR from 'lib/utils/stringWithWBR'
import { teamLogic } from '../teamLogic'
import { SceneExport } from 'scenes/sceneTypes'
import { LemonButton } from '../../lib/components/LemonButton'
import { LemonSpacer } from '../../lib/components/LemonRow'
import { LemonSwitch } from '../../lib/components/LemonSwitch/LemonSwitch'
import { LemonTable, LemonTableColumn, LemonTableColumns } from '../../lib/components/LemonTable/LemonTable'
import { More } from '../../lib/components/LemonButton/More'
import { createdAtColumn, createdByColumn } from '../../lib/components/LemonTable/columnUtils'
export const scene: SceneExport = {
component: FeatureFlags,
@ -26,78 +28,33 @@ export const scene: SceneExport = {
export function FeatureFlags(): JSX.Element {
const { currentTeamId } = useValues(teamLogic)
const { featureFlags, featureFlagsLoading, searchedFeatureFlags, searchTerm } = useValues(featureFlagsLogic)
const { featureFlagsLoading, searchedFeatureFlags, searchTerm } = useValues(featureFlagsLogic)
const { updateFeatureFlag, loadFeatureFlags, setSearchTerm } = useActions(featureFlagsLogic)
const { push } = useActions(router)
const { tableScrollX } = useIsTableScrolling('lg')
const columns = [
const columns: LemonTableColumns<FeatureFlagType> = [
{
title: normalizeColumnTitle('Key'),
dataIndex: 'key',
className: 'ph-no-capture',
fixed: 'left',
sticky: true,
width: '15%',
sorter: (a: FeatureFlagType, b: FeatureFlagType) => ('' + a.key).localeCompare(b.key),
render: function Render(_: string, featureFlag: FeatureFlagType) {
sorter: (a: FeatureFlagType, b: FeatureFlagType) => (a.key || '').localeCompare(b.key || ''),
render: function Render(_, featureFlag: FeatureFlagType) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
maxWidth: 210,
width: 'auto',
}}
>
{!featureFlag.active && (
<Tooltip title="This feature flag is disabled.">
<DisconnectOutlined style={{ marginRight: 4 }} />
</Tooltip>
)}
<div onClick={(e) => e.stopPropagation()}>
<CopyToClipboardInline
iconStyle={{ color: 'var(--primary)' }}
iconPosition="start"
explicitValue={featureFlag.key}
/>
</div>
<Typography.Text title={featureFlag.key}>{stringWithWBR(featureFlag.key, 17)}</Typography.Text>
</div>
<>
<Link to={featureFlag.id ? urls.featureFlag(featureFlag.id) : undefined}>
<h4 className="row-name">{stringWithWBR(featureFlag.key, 17)}</h4>
</Link>
{featureFlag.name && <span className="row-description">{featureFlag.name}</span>}
</>
)
},
},
createdByColumn<FeatureFlagType>() as LemonTableColumn<FeatureFlagType, keyof FeatureFlagType>,
createdAtColumn<FeatureFlagType>() as LemonTableColumn<FeatureFlagType, keyof FeatureFlagType>,
{
title: normalizeColumnTitle('Description'),
render: function Render(_: string, featureFlag: FeatureFlagType) {
return (
<div
style={{
display: 'flex',
wordWrap: 'break-word',
maxWidth: 450,
width: 'auto',
whiteSpace: 'break-spaces',
}}
>
<Typography.Paragraph
ellipsis={{
rows: 5,
}}
title={featureFlag.name}
>
{featureFlag.name}
</Typography.Paragraph>
</div>
)
},
className: 'ph-no-capture',
sorter: (a: FeatureFlagType, b: FeatureFlagType) => ('' + a.name).localeCompare(b.name),
},
createdAtColumn(),
createdByColumn(featureFlags),
{
title: 'Filters',
render: function Render(_: string, featureFlag: FeatureFlagType) {
title: 'Release conditions',
render: function Render(_, featureFlag: FeatureFlagType) {
if (!featureFlag.filters?.groups) {
return 'N/A'
}
@ -108,62 +65,76 @@ export function FeatureFlags(): JSX.Element {
},
},
{
title: 'Enabled',
title: 'Status',
sorter: (a: FeatureFlagType, b: FeatureFlagType) => Number(a.active) - Number(b.active),
width: 90,
align: 'right',
render: function RenderActive(_: string, featureFlag: FeatureFlagType) {
render: function RenderActive(_, featureFlag: FeatureFlagType) {
const switchId = `feature-flag-${featureFlag.id}-switch`
return (
<Switch
onClick={(_checked, e) => e.stopPropagation()}
checked={featureFlag.active}
onChange={(active) =>
featureFlag.id ? updateFeatureFlag({ id: featureFlag.id, payload: { active } }) : null
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<label htmlFor={switchId}>{featureFlag.active ? 'Enabled' : 'Disabled'}</label>
<LemonSwitch
id={switchId}
checked={featureFlag.active}
onChange={(active) =>
featureFlag.id ? updateFeatureFlag({ id: featureFlag.id, payload: { active } }) : null
}
style={{ marginLeft: '0.5rem' }}
/>
</div>
)
},
},
{
width: 100,
render: function Render(_, featureFlag: FeatureFlagType) {
return (
<More
overlay={
<>
<LemonButton
type="stealth"
onClick={() => {
copyToClipboard(featureFlag.key, 'feature flag key')
}}
fullWidth
>
Copy key
</LemonButton>
<LemonButton type="stealth" to={`/feature_flags/${featureFlag.id}`} fullWidth>
Edit
</LemonButton>
<LemonButton
type="stealth"
to={`/insights?events=[{"id":"$pageview","name":"$pageview","type":"events","math":"dau"}]&breakdown_type=event&breakdown=$feature/${featureFlag.key}`}
data-attr="usage"
fullWidth
>
Use in Insights
</LemonButton>
<LemonSpacer />
{featureFlag.id && (
<LemonButton
type="stealth"
style={{ color: 'var(--danger)' }}
onClick={() => {
deleteWithUndo({
endpoint: `projects/${currentTeamId}/feature_flags`,
object: { name: featureFlag.name, id: featureFlag.id },
callback: loadFeatureFlags,
})
}}
fullWidth
>
Delete feature flag
</LemonButton>
)}
</>
}
/>
)
},
},
{
title: normalizeColumnTitle('Usage'),
width: 100,
align: 'right',
render: function Render(_: string, featureFlag: FeatureFlagType) {
return (
<Link
to={`/insights?events=[{"id":"$pageview","name":"$pageview","type":"events","math":"dau"}]&breakdown_type=event&breakdown=$feature/${featureFlag.key}`}
data-attr="usage"
onClick={(e) => e.stopPropagation()}
>
Insights <ExportOutlined />
</Link>
)
},
},
{
title: normalizeColumnTitle('Actions'),
width: 100,
align: 'right',
render: function Render(_: string, featureFlag: FeatureFlagType) {
return (
<>
<Link to={`/feature_flags/${featureFlag.id}`}>
<EditOutlined />
</Link>
{featureFlag.id && (
<DeleteWithUndo
endpoint={`projects/${currentTeamId}/feature_flags`}
object={{ name: featureFlag.name, id: featureFlag.id }}
className="text-danger"
style={{ marginLeft: 8 }}
callback={loadFeatureFlags}
>
<DeleteOutlined />
</DeleteWithUndo>
)}
</>
)
},
},
]
return (
@ -193,19 +164,14 @@ export function FeatureFlags(): JSX.Element {
</LinkButton>
</div>
</div>
<Table
<LemonTable
dataSource={searchedFeatureFlags}
columns={columns}
loading={featureFlagsLoading && featureFlags.length === 0}
pagination={{ pageSize: 99999, hideOnSinglePage: true }}
onRow={(featureFlag) => ({
onClick: () => featureFlag.id && push(urls.featureFlag(featureFlag.id)),
style: !featureFlag.active ? { color: 'var(--muted)' } : {},
})}
size="small"
rowClassName="cursor-pointer"
rowKey="key"
loading={featureFlagsLoading}
defaultSorting={{ columnIndex: 2, order: 1 }}
pagination={{ pageSize: 20 }}
data-attr="feature-flag-table"
scroll={{ x: tableScrollX }}
/>
</div>
)

View File

@ -5,7 +5,6 @@ import { capitalizeFirstLetter, humanFriendlyDuration, pluralize } from 'lib/uti
import { PropertyKeyInfo } from 'lib/components/PropertyKeyInfo'
import { Button, ButtonProps, Popover } from 'antd'
import { ArrowRightOutlined, InfoCircleOutlined } from '@ant-design/icons'
import { useResizeObserver } from 'lib/utils/responsiveUtils'
import { SeriesGlyph } from 'lib/components/SeriesGlyph'
import { ArrowBottomRightOutlined, IconInfinity } from 'lib/components/icons'
import { funnelLogic } from './funnelLogic'
@ -32,6 +31,7 @@ import { EntityFilterInfo } from 'lib/components/EntityFilterInfo'
import { getActionFilterFromFunnelStep } from 'scenes/insights/InsightTabs/FunnelTab/funnelStepTableUtils'
import { FunnelStepDropdown } from './FunnelStepDropdown'
import { insightLogic } from 'scenes/insights/insightLogic'
import { useResizeObserver } from '../../lib/hooks/useResizeObserver'
interface BarProps {
percentage: number
@ -288,8 +288,8 @@ function Bar({
}
useResizeObserver({
callback: useThrottledCallback(decideLabelPosition, 200),
element: barRef,
onResize: useThrottledCallback(decideLabelPosition, 200),
ref: barRef,
})
return (
@ -417,8 +417,8 @@ function AverageTimeInspector({
}, [])
useResizeObserver({
callback: useThrottledCallback(decideTextVisible, 200),
element: wrapperRef,
onResize: useThrottledCallback(decideTextVisible, 200),
ref: wrapperRef,
})
return (

View File

@ -113,7 +113,7 @@ export const scene: SceneExport = {
logic: savedInsightsLogic,
}
const columnSort = (direction: 'up' | 'down' | 'none'): JSX.Element => (
export const columnSort = (direction: 'up' | 'down' | 'none'): JSX.Element => (
<div style={{ fontSize: 10, paddingLeft: 8, whiteSpace: 'nowrap' }}>
{direction === 'down' ? <ArrowDownOutlined /> : direction === 'up' ? <ArrowUpOutlined /> : null}
<MenuOutlined />

View File

@ -117,6 +117,7 @@
"rrweb": "^1.0.6",
"sass": "^1.26.2",
"use-debounce": "^6.0.1",
"use-resize-observer": "^8.0.0",
"zxcvbn": "^4.4.2"
},
"devDependencies": {

View File

@ -16320,6 +16320,13 @@ use-local-storage-state@^6.0.0:
resolved "https://registry.yarnpkg.com/use-local-storage-state/-/use-local-storage-state-6.0.3.tgz#65add61b8450b071354ce31b5a69b8908e69b497"
integrity sha512-VvaMTPhBcV+pT/MSkJKG1gdU9jTdY+liJ7XUXsuCcOKa2+P/WBB7Fe5/k+20XzgqCG8AloIiTOuoHfV9FlnYmQ==
use-resize-observer@^8.0.0:
version "8.0.0"
resolved "https://registry.yarnpkg.com/use-resize-observer/-/use-resize-observer-8.0.0.tgz#69bd80c1ddd94f3758563fe107efb25fed85067a"
integrity sha512-n0iKSeiQpJCyaFh5JA0qsVLBIovsF4EIIR1G6XiBwKJN66ZrD4Oj62bjcuTAATPKiSp6an/2UZZxCf/67fk3sQ==
dependencies:
"@juggle/resize-observer" "^3.3.1"
use@^3.1.0:
version "3.1.1"
resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"