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:
parent
b4abf84424
commit
2f1a6af0ef
@ -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')
|
||||
})
|
||||
|
@ -46,5 +46,4 @@
|
||||
margin: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
color: var(--primary-alt);
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
@ -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>
|
||||
))}
|
||||
|
@ -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 />
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -11,7 +11,7 @@
|
||||
background: var(--primary-bg-active);
|
||||
}
|
||||
&:disabled {
|
||||
opacity: 0.7;
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.LemonRow__icon {
|
||||
|
29
frontend/src/lib/components/LemonButton/More.tsx
Normal file
29
frontend/src/lib/components/LemonButton/More.tsx
Normal 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,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
@ -83,7 +83,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
&:not(:first-child) {
|
||||
padding-left: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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" />
|
||||
|
159
frontend/src/lib/components/LemonTable/LemonTable.scss
Normal file
159
frontend/src/lib/components/LemonTable/LemonTable.scss
Normal 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%);
|
||||
}
|
||||
}
|
@ -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 }}
|
||||
/>
|
||||
)
|
||||
}
|
255
frontend/src/lib/components/LemonTable/LemonTable.tsx
Normal file
255
frontend/src/lib/components/LemonTable/LemonTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
46
frontend/src/lib/components/LemonTable/columnUtils.tsx
Normal file
46
frontend/src/lib/components/LemonTable/columnUtils.tsx
Normal 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 || ''
|
||||
),
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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>
|
||||
|
@ -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 || ''
|
||||
),
|
||||
|
@ -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 (
|
||||
|
9
frontend/src/lib/hooks/useResizeObserver.ts
Normal file
9
frontend/src/lib/hooks/useResizeObserver.ts
Normal 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
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
|
@ -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>
|
||||
)
|
||||
|
@ -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 (
|
||||
|
@ -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 />
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
|
Loading…
Reference in New Issue
Block a user