diff --git a/cypress/integration/featureFlags.js b/cypress/integration/featureFlags.js index d3bba009f78..628e2d5e282 100644 --- a/cypress/integration/featureFlags.js +++ b/cypress/integration/featureFlags.js @@ -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') }) diff --git a/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.scss b/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.scss index 33945929deb..79d5981ab42 100644 --- a/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.scss +++ b/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.scss @@ -46,5 +46,4 @@ margin: 0 0.5rem; font-size: 1rem; color: var(--primary-alt); - transform: rotate(-90deg); } diff --git a/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.tsx b/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.tsx index c903f21fcb2..67602c77ac1 100644 --- a/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.tsx +++ b/frontend/src/layout/navigation/Breadcrumbs/Breadcrumbs.tsx @@ -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 { {breadcrumbs.slice(1).map((breadcrumb) => ( - + ))} diff --git a/frontend/src/layout/navigation/SideBar/SideBar.tsx b/frontend/src/layout/navigation/SideBar/SideBar.tsx index 7b5dfbbaf79..22f0e069a97 100644 --- a/frontend/src/layout/navigation/SideBar/SideBar.tsx +++ b/frontend/src/layout/navigation/SideBar/SideBar.tsx @@ -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
- + {currentTeam && ( <> diff --git a/frontend/src/lib/components/CopyToClipboard.tsx b/frontend/src/lib/components/CopyToClipboard.tsx index 9bd473d218c..0f4b8397929 100644 --- a/frontend/src/lib/components/CopyToClipboard.tsx +++ b/frontend/src/lib/components/CopyToClipboard.tsx @@ -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 { 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} > {children} - ) diff --git a/frontend/src/lib/components/LemonButton/LemonButton.scss b/frontend/src/lib/components/LemonButton/LemonButton.scss index 19330d98dc3..67d9aad235c 100644 --- a/frontend/src/lib/components/LemonButton/LemonButton.scss +++ b/frontend/src/lib/components/LemonButton/LemonButton.scss @@ -11,7 +11,7 @@ background: var(--primary-bg-active); } &:disabled { - opacity: 0.7; + opacity: 0.6; cursor: not-allowed; } .LemonRow__icon { diff --git a/frontend/src/lib/components/LemonButton/More.tsx b/frontend/src/lib/components/LemonButton/More.tsx new file mode 100644 index 00000000000..a9e19c03281 --- /dev/null +++ b/frontend/src/lib/components/LemonButton/More.tsx @@ -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): JSX.Element { + const [visible, setVisible] = useState(false) + + return ( + } + type="stealth" + onClick={(e) => { + setVisible((state) => !state) + e.stopPropagation() + }} + popup={{ + visible, + onClickOutside: () => setVisible(false), + onClickInside: () => setVisible(false), + placement: 'bottom-end', + actionable: true, + overlay, + }} + /> + ) +} diff --git a/frontend/src/lib/components/LemonRow/LemonRow.scss b/frontend/src/lib/components/LemonRow/LemonRow.scss index d37b800943d..56c98db0a26 100644 --- a/frontend/src/lib/components/LemonRow/LemonRow.scss +++ b/frontend/src/lib/components/LemonRow/LemonRow.scss @@ -83,7 +83,7 @@ display: flex; align-items: center; &:not(:first-child) { - padding-left: 0.5rem; + margin-left: 0.5rem; } } diff --git a/frontend/src/lib/components/LemonSwitch/LemonSwitch.scss b/frontend/src/lib/components/LemonSwitch/LemonSwitch.scss index 651682b4226..58da8cdf157 100644 --- a/frontend/src/lib/components/LemonSwitch/LemonSwitch.scss +++ b/frontend/src/lib/components/LemonSwitch/LemonSwitch.scss @@ -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); } diff --git a/frontend/src/lib/components/LemonSwitch/LemonSwitch.tsx b/frontend/src/lib/components/LemonSwitch/LemonSwitch.tsx index 51d96f3f6bc..28d40e81da3 100644 --- a/frontend/src/lib/components/LemonSwitch/LemonSwitch.tsx +++ b/frontend/src/lib/components/LemonSwitch/LemonSwitch.tsx @@ -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} >
diff --git a/frontend/src/lib/components/LemonTable/LemonTable.scss b/frontend/src/lib/components/LemonTable/LemonTable.scss new file mode 100644 index 00000000000..8e19b0a9eab --- /dev/null +++ b/frontend/src/lib/components/LemonTable/LemonTable.scss @@ -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%); + } +} diff --git a/frontend/src/lib/components/LemonTable/LemonTable.stories.tsx b/frontend/src/lib/components/LemonTable/LemonTable.stories.tsx new file mode 100644 index 00000000000..4ce08394a26 --- /dev/null +++ b/frontend/src/lib/components/LemonTable/LemonTable.stories.tsx @@ -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 + +export function LemonTable({ loading }: { loading: boolean }): JSX.Element { + return ( + <_LemonTable + loading={loading} + columns={[{ title: 'Column' }]} + dataSource={[] as Record[]} + pagination={{ pageSize: 10 }} + /> + ) +} diff --git a/frontend/src/lib/components/LemonTable/LemonTable.tsx b/frontend/src/lib/components/LemonTable/LemonTable.tsx new file mode 100644 index 00000000000..85887c582d6 --- /dev/null +++ b/frontend/src/lib/components/LemonTable/LemonTable.tsx @@ -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, 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> = LemonTableColumn[] + +export interface LemonTableProps> { + /** Element key that will also be used in pagination to improve search param uniqueness. */ + key?: string + columns: LemonTableColumns + 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, 'key'> + loading?: boolean + pagination?: { pageSize: number; hideOnSinglePage?: boolean } + /** Sorting order to start with. */ + defaultSorting?: Sorting + 'data-attr'?: string +} + +export function LemonTable>({ + key, + columns, + dataSource, + rowKey, + onRow, + loading, + pagination, + defaultSorting, + ...divProps +}: LemonTableProps): 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>>( + (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(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 ( +
+
+
+ + + + {columns.map((column, columnIndex) => ( + + ))} + + + + {dataSource.length ? ( + currentFrame.map((data, rowIndex) => ( + + {columns.map((column, columnIndex) => { + const value = column.dataIndex ? data[column.dataIndex] : undefined + const contents = column.render ? column.render(value, data) : value + return ( + + ) + })} + + )) + ) : ( + + + + )} + +
sortingDispatch({ columnIndex }) : undefined} + > +
+ {column.title} + {column.sorter && + columnSort( + sortingState && sortingState.columnIndex === columnIndex + ? sortingState.order === 1 + ? 'up' + : 'down' + : 'none' + )} +
+
+ {contents} +
No data
+ {showPagination && ( +
+ + {currentFrame.length === 0 + ? 'No entries' + : currentFrame.length === 1 + ? `${currentEndIndex} of ${dataSource.length} entries` + : `${currentStartIndex + 1}-${currentEndIndex} of ${dataSource.length} entries`} + + } + type="stealth" + disabled={currentPage === 1} + onClick={() => setCurrentPage(Math.max(1, Math.min(pageCount, currentPage) - 1))} + /> + } + type="stealth" + disabled={currentPage === pageCount} + onClick={() => setCurrentPage(Math.min(pageCount, currentPage + 1))} + /> +
+ )} +
+
+
+
+
+ ) +} diff --git a/frontend/src/lib/components/LemonTable/columnUtils.tsx b/frontend/src/lib/components/LemonTable/columnUtils.tsx new file mode 100644 index 00000000000..61b42f1d867 --- /dev/null +++ b/frontend/src/lib/components/LemonTable/columnUtils.tsx @@ -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(): LemonTableColumn { + return { + title: normalizeColumnTitle('Created'), + align: 'right', + render: function RenderCreatedAt(_, item) { + return item.created_at ? ( +
+ +
+ ) : ( + '-' + ) + }, + sorter: (a, b) => (new Date(a.created_at || 0) > new Date(b.created_at || 0) ? 1 : -1), + } +} + +export function createdByColumn(): LemonTableColumn { + return { + title: normalizeColumnTitle('Created by'), + render: function Render(_: any, item: any) { + return ( + + {item.created_by && ( + + )} +
+ {item.created_by ? item.created_by.first_name || item.created_by.email : '-'} +
+
+ ) + }, + sorter: (a, b) => + (a.created_by?.first_name || a.created_by?.email || '').localeCompare( + b.created_by?.first_name || b.created_by?.email || '' + ), + } +} diff --git a/frontend/src/lib/components/Popup/Popup.scss b/frontend/src/lib/components/Popup/Popup.scss index b97ea87f00f..c843ae5209e 100644 --- a/frontend/src/lib/components/Popup/Popup.scss +++ b/frontend/src/lib/components/Popup/Popup.scss @@ -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; } } diff --git a/frontend/src/lib/components/Popup/Popup.tsx b/frontend/src/lib/components/Popup/Popup.tsx index 5891663fd54..e7b5cc6e653 100644 --- a/frontend/src/lib/components/Popup/Popup.tsx +++ b/frontend/src/lib/components/Popup/Popup.tsx @@ -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 /** 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>[]>( () => [ + { + 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} > {overlay} diff --git a/frontend/src/lib/components/Table/Table.tsx b/frontend/src/lib/components/Table/Table.tsx index 5316dd2884a..1c5875a8747 100644 --- a/frontend/src/lib/components/Table/Table.tsx +++ b/frontend/src/lib/components/Table/Table.tsx @@ -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 { +export function createdAtColumn = Record>(): ColumnType { return { title: normalizeColumnTitle('Created'), - render: function RenderCreatedAt(_: any, item: Record): JSX.Element | undefined | '' { + align: 'right', + render: function RenderCreatedAt(_, item): JSX.Element | undefined | '' { return ( item.created_at && (
@@ -17,24 +21,28 @@ export function createdAtColumn(): Record { ) ) }, - sorter: (a: Record, b: Record) => - 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[]): Record { +export function createdByColumn = Record>(items: T[]): ColumnType { const { user } = useValues(userLogic) return { title: normalizeColumnTitle('Created by'), render: function Render(_: any, item: any) { return ( -
- {item.created_by ? item.created_by.first_name || item.created_by.email : '-'} -
+ + {item.created_by && ( + + )} +
+ {item.created_by ? item.created_by.first_name || item.created_by.email : '-'} +
+
) }, filters: uniqueBy( - items.map((item: Record) => { + items.map((item: T) => { if (!item.created_by) { return { text: '(none)', @@ -57,9 +65,8 @@ export function createdByColumn(items: Record[]): Record) => - (value === null && item.created_by === null) || item.created_by?.uuid === value, - sorter: (a: Record, b: Record) => + 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 || '' ), diff --git a/frontend/src/lib/components/icons.tsx b/frontend/src/lib/components/icons.tsx index dfbef680ad3..c9665020915 100644 --- a/frontend/src/lib/components/icons.tsx +++ b/frontend/src/lib/components/icons.tsx @@ -552,6 +552,30 @@ export function IconUnfoldLess({ style }: { style?: CSSProperties }): JSX.Elemen ) } +/** Material Design Chevron Left icon. */ +export function IconChevronLeft(props: React.SVGProps): JSX.Element { + return ( + + + + ) +} + +/** Material Design Chevron Right icon. */ +export function IconChevronRight(props: React.SVGProps): JSX.Element { + return ( + + + + ) +} + /** Material Design Add icon. */ export function IconPlus(props: React.SVGProps): JSX.Element { return ( @@ -664,6 +688,7 @@ export function IconBarChart(): JSX.Element { ) } +/** Material Design Bar Speed icon. */ export function IconGauge(): JSX.Element { return ( @@ -838,6 +863,30 @@ export function IconExpandMore(props: React.SVGProps): JSX.Elemen ) } +/** Material Design More Horiz icon. */ +export function IconEllipsis(props: React.SVGProps): JSX.Element { + return ( + + + + ) +} + +/** Material Design Content Copy icon. */ +export function IconCopy(props: React.SVGProps): JSX.Element { + return ( + + + + ) +} + /** Material Design Receipt icon. */ export function IconBill(): JSX.Element { return ( diff --git a/frontend/src/lib/hooks/useResizeObserver.ts b/frontend/src/lib/hooks/useResizeObserver.ts new file mode 100644 index 00000000000..5ca63a2182d --- /dev/null +++ b/frontend/src/lib/hooks/useResizeObserver.ts @@ -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 diff --git a/frontend/src/lib/utils.tsx b/frontend/src/lib/utils.tsx index 578dc11610e..14d8da83a51 100644 --- a/frontend/src/lib/utils.tsx +++ b/frontend/src/lib/utils.tsx @@ -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(

Copied to clipboard!

-

{descriptionAdjusted} has been copied to your clipboard.

+

{capitalizeFirstLetter(description)} has been copied to your clipboard.

) return true } catch (e) { - toast.error(`Could not copy ${descriptionAdjusted}to clipboard: ${e}`) + toast.error(`Could not copy ${description} to clipboard: ${e}`) return false } } diff --git a/frontend/src/lib/utils/responsiveUtils.tsx b/frontend/src/lib/utils/responsiveUtils.tsx index 973c225f76e..a96657793ca 100644 --- a/frontend/src/lib/utils/responsiveUtils.tsx +++ b/frontend/src/lib/utils/responsiveUtils.tsx @@ -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 -} - -export function useResizeObserver({ callback, element }: ResizeObserverProps): void { - const observer = useRef(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) - } - } -} diff --git a/frontend/src/scenes/cohorts/Cohorts.tsx b/frontend/src/scenes/cohorts/Cohorts.tsx index 7a2816f823a..38c4b4a36b1 100644 --- a/frontend/src/scenes/cohorts/Cohorts.tsx +++ b/frontend/src/scenes/cohorts/Cohorts.tsx @@ -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[] = [ { 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), diff --git a/frontend/src/scenes/feature-flags/FeatureFlags.tsx b/frontend/src/scenes/feature-flags/FeatureFlags.tsx index 4efb9be5f50..e7d73055238 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlags.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlags.tsx @@ -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 = [ { 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 ( -
- {!featureFlag.active && ( - - - - )} -
e.stopPropagation()}> - -
- {stringWithWBR(featureFlag.key, 17)} -
+ <> + +

{stringWithWBR(featureFlag.key, 17)}

+ + {featureFlag.name && {featureFlag.name}} + ) }, }, + createdByColumn() as LemonTableColumn, + createdAtColumn() as LemonTableColumn, { - title: normalizeColumnTitle('Description'), - render: function Render(_: string, featureFlag: FeatureFlagType) { - return ( -
- - {featureFlag.name} - -
- ) - }, - 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 ( - e.stopPropagation()} - checked={featureFlag.active} - onChange={(active) => - featureFlag.id ? updateFeatureFlag({ id: featureFlag.id, payload: { active } }) : null +
+ + + featureFlag.id ? updateFeatureFlag({ id: featureFlag.id, payload: { active } }) : null + } + style={{ marginLeft: '0.5rem' }} + /> +
+ ) + }, + }, + { + width: 100, + render: function Render(_, featureFlag: FeatureFlagType) { + return ( + + { + copyToClipboard(featureFlag.key, 'feature flag key') + }} + fullWidth + > + Copy key + + + Edit + + + Use in Insights + + + {featureFlag.id && ( + { + deleteWithUndo({ + endpoint: `projects/${currentTeamId}/feature_flags`, + object: { name: featureFlag.name, id: featureFlag.id }, + callback: loadFeatureFlags, + }) + }} + fullWidth + > + Delete feature flag + + )} + } /> ) }, }, - { - title: normalizeColumnTitle('Usage'), - width: 100, - align: 'right', - render: function Render(_: string, featureFlag: FeatureFlagType) { - return ( - e.stopPropagation()} - > - Insights - - ) - }, - }, - { - title: normalizeColumnTitle('Actions'), - width: 100, - align: 'right', - render: function Render(_: string, featureFlag: FeatureFlagType) { - return ( - <> - - - - {featureFlag.id && ( - - - - )} - - ) - }, - }, ] return ( @@ -193,19 +164,14 @@ export function FeatureFlags(): JSX.Element {
- ({ - 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 }} /> ) diff --git a/frontend/src/scenes/funnels/FunnelBarGraph.tsx b/frontend/src/scenes/funnels/FunnelBarGraph.tsx index bc24ee238da..0e6268e8eea 100644 --- a/frontend/src/scenes/funnels/FunnelBarGraph.tsx +++ b/frontend/src/scenes/funnels/FunnelBarGraph.tsx @@ -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 ( diff --git a/frontend/src/scenes/saved-insights/SavedInsights.tsx b/frontend/src/scenes/saved-insights/SavedInsights.tsx index 250f3da4a69..7ac103abc7b 100644 --- a/frontend/src/scenes/saved-insights/SavedInsights.tsx +++ b/frontend/src/scenes/saved-insights/SavedInsights.tsx @@ -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 => (
{direction === 'down' ? : direction === 'up' ? : null} diff --git a/package.json b/package.json index 5a6c0490e8c..b8deeb767b4 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/yarn.lock b/yarn.lock index a5c25511216..909fc0f76b2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"