From bfd9f6b9b56ffa47fcbd3dab4fde107ec27e1666 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Zugmeyer?= Date: Fri, 4 Aug 2023 19:08:45 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20add=20columns=20to=20the=20event=20?= =?UTF-8?q?list?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/tabs/eventsTab/columnUtils.ts | 26 ++ .../panel/components/tabs/eventsTab/drag.ts | 121 ++++++ .../components/tabs/eventsTab/eventRow.tsx | 131 ++++-- .../components/tabs/eventsTab/eventsList.tsx | 388 ++++++++++++++++-- .../components/tabs/eventsTab/eventsTab.tsx | 11 +- .../panel/components/tabs/eventsTab/grid.tsx | 85 ++-- .../panel/hooks/useEvents/facetRegistry.ts | 41 +- developer-extension/src/panel/uiUtils.ts | 10 +- 8 files changed, 718 insertions(+), 95 deletions(-) create mode 100644 developer-extension/src/panel/components/tabs/eventsTab/columnUtils.ts create mode 100644 developer-extension/src/panel/components/tabs/eventsTab/drag.ts diff --git a/developer-extension/src/panel/components/tabs/eventsTab/columnUtils.ts b/developer-extension/src/panel/components/tabs/eventsTab/columnUtils.ts new file mode 100644 index 0000000000..3c0c046a8f --- /dev/null +++ b/developer-extension/src/panel/components/tabs/eventsTab/columnUtils.ts @@ -0,0 +1,26 @@ +export type EventListColumn = + | { type: 'date' } + | { type: 'description' } + | { type: 'type' } + | { type: 'field'; path: string } + +export const DEFAULT_COLUMNS: EventListColumn[] = [{ type: 'date' }, { type: 'type' }, { type: 'description' }] + +export function includesColumn(existingColumns: EventListColumn[], newColumn: EventListColumn) { + return existingColumns.some((column) => { + if (column.type === 'field' && newColumn.type === 'field') { + return column.path === newColumn.path + } + return column.type === newColumn.type + }) +} + +export function getColumnTitle(column: EventListColumn) { + return column.type === 'date' + ? 'Date' + : column.type === 'description' + ? 'Description' + : column.type === 'type' + ? 'Type' + : column.path +} diff --git a/developer-extension/src/panel/components/tabs/eventsTab/drag.ts b/developer-extension/src/panel/components/tabs/eventsTab/drag.ts new file mode 100644 index 0000000000..5d2e0dff0e --- /dev/null +++ b/developer-extension/src/panel/components/tabs/eventsTab/drag.ts @@ -0,0 +1,121 @@ +export interface Coordinates { + x: number + y: number +} + +/** + * This is a framework agnostic drag implementation that works as a state machine: + * + * ``` + * (init) + * | + * [onStart] + * | \______________ + * | \ + * + * | ____ | + * | / \ (end) + * [onMove] ) + * | \ \____/ + * | \______________ + * | \ + * | + * | + * | | + * [onDrop] [onAbort] + * | _________________/ + * |/ + * (end) + * ``` + */ +export function initDrag({ + target, + onStart, + onMove, + onAbort, + onDrop, +}: { + target: HTMLElement + onStart: (event: { target: HTMLElement; position: Coordinates }) => boolean | void + onMove: (event: { position: Coordinates }) => void + onDrop: () => void + onAbort: () => void +}) { + type DragState = + | { isDragging: false } + | { + isDragging: true + removeListeners: () => void + } + + let state: DragState = { + isDragging: false, + } + + target.addEventListener('pointerdown', onPointerDown, { capture: true }) + + return { + stop: () => { + endDrag(true) + target.removeEventListener('pointerdown', onPointerDown, { capture: true }) + }, + } + + function onPointerDown(event: PointerEvent) { + if ( + state.isDragging || + event.buttons !== 1 || + onStart({ target: event.target as HTMLElement, position: { x: event.clientX, y: event.clientY } }) === false + ) { + return + } + + event.preventDefault() + + state = { + isDragging: true, + removeListeners: () => { + removeEventListener('pointerup', onPointerUp, { capture: true }) + removeEventListener('pointermove', onPointerMove, { capture: true }) + }, + } + + addEventListener('pointerup', onPointerUp, { capture: true }) + addEventListener('pointermove', onPointerMove, { capture: true }) + } + + function onPointerUp(_event: PointerEvent) { + endDrag(false) + } + + function onPointerMove(event: PointerEvent) { + if (!state.isDragging) { + return + } + + if (event.buttons !== 1) { + // The user might have released the click outside of the window + endDrag(true) + return + } + + onMove({ + position: { + x: event.clientX, + y: event.clientY, + }, + }) + } + + function endDrag(abort: boolean) { + if (state.isDragging) { + if (abort) { + onAbort() + } else { + onDrop() + } + state.removeListeners() + state = { isDragging: false } + } + } +} diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx index 8a8aba5d2b..bef1803938 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/eventRow.tsx @@ -1,4 +1,4 @@ -import { Badge, Box } from '@mantine/core' +import { Badge, Box, Menu } from '@mantine/core' import type { ReactNode } from 'react' import React, { useRef, useState } from 'react' import type { TelemetryEvent } from '../../../../../../packages/core/src/domain/telemetry' @@ -15,7 +15,10 @@ import { isTelemetryEvent, isLogEvent, isRumEvent } from '../../../sdkEvent' import { formatDuration } from '../../../formatNumber' import { Json } from '../../json' import { LazyCollapse } from '../../lazyCollapse' +import type { FacetRegistry } from '../../../hooks/useEvents' import { Grid } from './grid' +import type { EventListColumn} from './columnUtils'; +import { includesColumn } from './columnUtils' const RUM_EVENT_TYPE_COLOR = { action: 'violet', @@ -46,41 +49,99 @@ const RESOURCE_TYPE_LABELS: Record = { other: 'Other', } -export const EventRow = React.memo(({ event }: { event: SdkEvent }) => { - const [isCollapsed, setIsCollapsed] = useState(true) - const collapseRef = useRef(null) +export const EventRow = React.memo( + ({ + event, + columns, + facetRegistry, + onAddColumn, + }: { + event: SdkEvent + columns: EventListColumn[] + facetRegistry: FacetRegistry + onAddColumn: (newColumn: EventListColumn) => void + }) => { + const [isCollapsed, setIsCollapsed] = useState(true) + const collapseRef = useRef(null) - return ( - { - if (collapseRef.current?.contains(event.target as Node)) { - // Ignore clicks on the collapsible area - return - } - setIsCollapsed((previous) => !previous) - }} - > - {new Date(event.date).toLocaleTimeString()} - - {isRumEvent(event) || isTelemetryEvent(event) ? ( - - {event.type} - - ) : ( - - {event.origin as string} {event.status as string} - - )} - - - - - - - - - ) -}) + function getMenuItemsForPath(path: string, _value: unknown) { + const newColumn: EventListColumn = { type: 'field', path } + if (!path || includesColumn(columns, newColumn)) { + return null + } + return ( + <> + { + onAddColumn(newColumn) + }} + > + Add column + + + ) + } + + return ( + + {columns.map((column): React.ReactElement => { + switch (column.type) { + case 'date': + return {new Date(event.date).toLocaleTimeString()} + case 'description': + return ( + { + if (collapseRef.current?.contains(event.target as Node)) { + // Ignore clicks on the collapsible area + return + } + setIsCollapsed((previous) => !previous) + }} + > + + + + + + ) + case 'type': + return ( + + {isRumEvent(event) || isTelemetryEvent(event) ? ( + + {event.type} + + ) : ( + + {event.origin as string} {event.status as string} + + )} + + ) + case 'field': { + const value = facetRegistry.getFieldValueForEvent(event, column.path) + return ( + + {value !== undefined && ( + + )} + + ) + } + } + })} + + ) + } +) export const EventDescription = React.memo(({ event }: { event: SdkEvent }) => { if (isRumEvent(event)) { diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventsList.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventsList.tsx index 1cfc5b7655..b7d0911155 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/eventsList.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/eventsList.tsx @@ -1,33 +1,373 @@ -import { Text, Center } from '@mantine/core' -import React from 'react' -import type { EventFilters } from '../../../hooks/useEvents' +import { ActionIcon, Popover, Box, Text, Button, Flex, Autocomplete } from '@mantine/core' +import type { ForwardedRef, ReactNode, RefObject } from 'react' +import React, { useMemo, useEffect, useRef, useState, forwardRef, useCallback } from 'react' +import type { EventFilters, FacetRegistry } from '../../../hooks/useEvents' import type { SdkEvent } from '../../../sdkEvent' import { isRumViewEvent } from '../../../sdkEvent' +import { BORDER_RADIUS } from '../../../uiUtils' +import type { EventListColumn } from './columnUtils' +import { getColumnTitle, DEFAULT_COLUMNS, includesColumn } from './columnUtils' +import type { Coordinates } from './drag' +import { initDrag } from './drag' import { EventRow } from './eventRow' -import { Grid } from './grid' - -export function EventsList({ events, filters }: { events: SdkEvent[]; filters: EventFilters }) { - if (!events.length) { - return ( -
- - No events - -
- ) +import { Grid, HORIZONTAL_PADDING, VERTICAL_PADDING } from './grid' + +export function EventsList({ + events, + filters, + facetRegistry, +}: { + events: SdkEvent[] + filters: EventFilters + facetRegistry: FacetRegistry +}) { + const headerRowRef = useRef(null) + const [columns, setColumns] = useState(DEFAULT_COLUMNS) + const dragGhost = useColumnDrag({ headerRowRef, columns, setColumns }) + + const onAddColumn = useCallback((column: EventListColumn) => { + setColumns((previous) => { + if (!includesColumn(previous, column)) { + return previous.concat(column) + } + return previous + }) + }, []) + + return ( + <> + + + {columns.map((column, index) => ( + + + {getColumnTitle(column)} + {index === columns.length - 1 && ( + + )} + + + ))} + + {events.map((event) => ( + + ))} + + {dragGhost} + + ) +} + +function AddColumnPopover({ + columns, + setColumns, + facetRegistry, +}: { + columns: EventListColumn[] + setColumns: (columns: EventListColumn[]) => void + facetRegistry: FacetRegistry +}) { + return ( + + + + + ({ background: theme.colorScheme === 'dark' ? theme.colors.dark[7] : theme.white })} + > + + {DEFAULT_COLUMNS.map((column) => ( + + ))} + + + + + ) +} + +function AddDefaultColumnButton({ + column, + columns, + setColumns, +}: { + column: EventListColumn + columns: EventListColumn[] + setColumns: (columns: EventListColumn[]) => void +}) { + if (includesColumn(columns, column)) { + return null } + return ( + + {getColumnTitle(column)} + + + ) +} + +function AddFieldColumn({ + columns, + setColumns, + facetRegistry, +}: { + columns: EventListColumn[] + setColumns: (columns: EventListColumn[]) => void + facetRegistry: FacetRegistry +}) { + const [input, setInput] = useState('') + + function addFieldColumn(path: string) { + const newColumn: EventListColumn = { path, type: 'field' } + if (!includesColumn(columns, newColumn)) { + setColumns(columns.concat(newColumn)) + } + } + + const allPaths = useMemo( + () => + Array.from(facetRegistry.getAllFieldPaths()).sort((a, b) => { + // Sort private fields last + if (a.startsWith('_dd') !== b.startsWith('_dd')) { + if (a.startsWith('_dd')) { + return 1 + } + if (b.startsWith('_dd')) { + return -1 + } + } + return a < b ? -1 : 1 + }), + [] + ) + + const suggestions = allPaths.filter((path) => path.includes(input)) return ( - - - Date - Type - Description - - {events.map((event) => ( - - ))} - + + { + event.preventDefault() + addFieldColumn(input) + }} + sx={{ display: 'contents' }} + > + ) => { + const inputIndex = value.indexOf(input) + let renderedValue: ReactNode + if (inputIndex < 0) { + renderedValue = value + } else { + renderedValue = ( + <> + {value.slice(0, inputIndex)} + + {value.slice(inputIndex, inputIndex + input.length)} + + {value.slice(inputIndex + input.length)} + + ) + } + + return ( + + {renderedValue} + + ) + })} + onItemSubmit={({ value }) => addFieldColumn(value)} + /> + + + + ) +} + +function getClosestCell(target: HTMLElement) { + if (target.closest('button, .mantine-Popover-dropdown')) { + return null + } + return target.closest('[data-header-cell]') +} + +/** Number of pixel to determine if the cursor is close enough of a position to trigger an action */ +const ACTION_DISTANCE_THRESHOLD = 20 + +function useColumnDrag({ + headerRowRef, + columns, + setColumns, +}: { + headerRowRef: RefObject + columns: EventListColumn[] + setColumns: (columns: EventListColumn[]) => void +}) { + interface DragState { + targetRect: DOMRect + startPosition: Coordinates + position: Coordinates + action?: DragAction + moved: boolean + insertPlaces: Place[] + columnIndex: number + } + + interface Place { + index: number + xPosition: number + } + + type DragAction = { type: 'delete' } | { type: 'insert'; place: Place } + + const [drag, setDrag] = useState(null) + + useEffect(() => { + if (columns.length <= 1) { + return + } + + const { stop } = initDrag({ + target: headerRowRef.current!, + + onStart({ target, position }) { + const targetCell = getClosestCell(target) + if (!targetCell) { + return false + } + const siblings = Array.from(targetCell.parentElement!.children) + const columnIndex = siblings.indexOf(targetCell) + + setDrag({ + targetRect: targetCell.getBoundingClientRect(), + insertPlaces: siblings.flatMap((sibling, index) => { + if (sibling === targetCell) { + return [] + } + return { + xPosition: sibling.getBoundingClientRect()[index < columnIndex ? 'left' : 'right'], + index, + } + }), + startPosition: position, + position, + moved: false, + action: undefined, + columnIndex, + }) + }, + + onMove({ position }) { + setDrag((drag) => { + if (!drag) { + return drag + } + let action: DragAction | undefined + if (Math.abs(drag.startPosition.y - position.y) > ACTION_DISTANCE_THRESHOLD) { + action = { type: 'delete' } + } else { + const insertPlace = drag.insertPlaces.find( + ({ xPosition }) => Math.abs(position.x - xPosition) < ACTION_DISTANCE_THRESHOLD + ) + if (insertPlace) { + action = { type: 'insert', place: insertPlace } + } + } + return { ...drag, action, position, moved: true } + }) + }, + + onDrop() { + setDrag((drag) => { + if (drag?.action) { + // Mmh, setting a state in a "set state" callback, not sure about that. + switch (drag.action.type) { + case 'delete': { + const newColumns = columns.slice() + newColumns.splice(drag.columnIndex, 1) + setColumns(newColumns) + break + } + case 'insert': { + const newColumns = columns.slice() + const [column] = newColumns.splice(drag.columnIndex, 1) + newColumns.splice(drag.action.place.index, 0, column) + setColumns(newColumns) + break + } + } + } + return null + }) + }, + + onAbort() { + setDrag(null) + }, + }) + + return stop + }, [columns]) + + return ( + drag && ( + + {getColumnTitle(columns[drag.columnIndex])} + + ) ) } diff --git a/developer-extension/src/panel/components/tabs/eventsTab/eventsTab.tsx b/developer-extension/src/panel/components/tabs/eventsTab/eventsTab.tsx index 80820e96f7..39cd05953c 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/eventsTab.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/eventsTab.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { Center, Text } from '@mantine/core' import type { EventFilters, FacetRegistry } from '../../../hooks/useEvents' import { TabBase } from '../../tabBase' import type { SdkEvent } from '../../../sdkEvent' @@ -20,7 +21,15 @@ export function EventsTab({ events, facetRegistry, filters, onFiltered, clear }: top={} leftSide={} > - + {events.length === 0 || !facetRegistry ? ( +
+ + No events + +
+ ) : ( + + )} ) } diff --git a/developer-extension/src/panel/components/tabs/eventsTab/grid.tsx b/developer-extension/src/panel/components/tabs/eventsTab/grid.tsx index 18cd1b58ae..c30377a99b 100644 --- a/developer-extension/src/panel/components/tabs/eventsTab/grid.tsx +++ b/developer-extension/src/panel/components/tabs/eventsTab/grid.tsx @@ -1,55 +1,90 @@ -import { Box, Text, useMantineTheme } from '@mantine/core' -import type { ComponentPropsWithoutRef, ReactNode } from 'react' -import React from 'react' +import type { BoxProps } from '@mantine/core' +import { Box, Text } from '@mantine/core' +import type { ComponentPropsWithoutRef, ForwardedRef, ReactNode } from 'react' +import React, { forwardRef } from 'react' +import { BORDER_RADIUS, separatorBorder } from '../../../uiUtils' -const HORIZONTAL_PADDING = 12 -const VERTICAL_PADDING = 6 +export const HORIZONTAL_PADDING = 16 +export const VERTICAL_PADDING = 6 export function Grid({ children, columnsCount }: { children: ReactNode; columnsCount: number }) { return ( 'auto').join(' ')} 1fr`, + gridTemplateColumns: `${Array.from({ length: columnsCount - 1 }, () => 'auto').join(' ')} minmax(200px, 1fr)`, }} + mx="md" > {children} ) } -Grid.HeaderCell = function ({ children }: { children: ReactNode }) { +Grid.HeaderCell = forwardRef(function ( + { children, ...props }: { children: ReactNode } & BoxProps & ComponentPropsWithoutRef<'div'>, + ref: ForwardedRef +) { return ( - + ({ + borderTop: separatorBorder(theme), + ':first-of-type': { borderTopLeftRadius: BORDER_RADIUS }, + ':last-of-type': { borderTopRightRadius: BORDER_RADIUS }, + })} + > {children} ) -} +}) -Grid.Cell = function ({ children, center }: { children: ReactNode; center?: boolean }) { - const theme = useMantineTheme() - const borderColor = theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3] +Grid.Cell = forwardRef(function ( + { + children, + center, + ...props + }: { children: ReactNode; center?: boolean } & BoxProps & ComponentPropsWithoutRef<'div'>, + ref: ForwardedRef +) { return ( ({ + position: 'relative', + borderBottom: separatorBorder(theme), + paddingLeft: HORIZONTAL_PADDING / 2, + paddingRight: HORIZONTAL_PADDING / 2, + paddingTop: VERTICAL_PADDING, + paddingBottom: VERTICAL_PADDING, + ':first-of-type': { + borderLeft: separatorBorder(theme), + paddingLeft: HORIZONTAL_PADDING, + }, + ':last-of-type': { + borderRight: separatorBorder(theme), + paddingRight: HORIZONTAL_PADDING, + }, + textAlign: center ? 'center' : undefined, + }), + ...(Array.isArray(props.sx) ? props.sx : [props.sx]), + ]} > {children} ) -} +}) -Grid.Row = function ({ children, ...props }: { children: ReactNode } & ComponentPropsWithoutRef<'div'>) { +Grid.Row = forwardRef(function ( + { children, ...props }: { children: ReactNode } & ComponentPropsWithoutRef<'div'>, + ref: ForwardedRef +) { return ( ) -} +}) diff --git a/developer-extension/src/panel/hooks/useEvents/facetRegistry.ts b/developer-extension/src/panel/hooks/useEvents/facetRegistry.ts index 5931526755..a6c3ae1628 100644 --- a/developer-extension/src/panel/hooks/useEvents/facetRegistry.ts +++ b/developer-extension/src/panel/hooks/useEvents/facetRegistry.ts @@ -5,12 +5,14 @@ import { FACETS, type FacetId, type FacetValue } from '../../facets.constants' const logger = createLogger('facetRegistry') type FieldPath = string -type FieldValue = string | number | null | boolean +type FieldValue = string | number | null | boolean | object type FieldMultiValue = FieldValue | FieldValue[] export class FacetRegistry { facetValueCounts: Map> = new Map(FACETS.map((facet) => [facet.id, new Map()])) eventFacetsCache: WeakMap> = new WeakMap() + eventFieldsCache: WeakMap> = new WeakMap() + allEventFieldPaths: Set = new Set() addEvent(event: SdkEvent) { const fields = getAllFields(event) @@ -40,18 +42,32 @@ export class FacetRegistry { } this.eventFacetsCache.set(event, facetsCache) + this.eventFieldsCache.set(event, fields) + + for (const fieldPath of fields.keys()) { + this.allEventFieldPaths.add(fieldPath) + } } getFacetValueForEvent(event: SdkEvent, facetId: FacetId): FacetValue | undefined { return this.eventFacetsCache.get(event)?.get(facetId) } + getFieldValueForEvent(event: SdkEvent, path: FieldPath): FieldMultiValue | undefined { + return this.eventFieldsCache.get(event)?.get(path) + } + getFacetValueCounts(facetId: FacetId): Map { return this.facetValueCounts.get(facetId)! } + getAllFieldPaths() { + return this.allEventFieldPaths + } + clear() { this.facetValueCounts = new Map(FACETS.map((facet) => [facet.id, new Map()])) + this.allEventFieldPaths.clear() } } @@ -68,6 +84,9 @@ export function getAllFields(event: object) { getAllFieldsRec(item, path) } } else if (typeof value === 'object' && value !== null) { + if (path !== undefined) { + pushField(path, value) + } for (const key in value) { if (Object.prototype.hasOwnProperty.call(value, key)) { const itemPath = path === undefined ? key : `${path}.${key}` @@ -79,16 +98,20 @@ export function getAllFields(event: object) { path !== undefined && (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean' || value === null) ) { - const previousValue = fields.get(path) - if (Array.isArray(previousValue)) { - previousValue.push(value) - } else if (previousValue !== undefined) { - fields.set(path, [previousValue, value]) - } else { - fields.set(path, value) - } + pushField(path, value) } else { logger.error(`Unexpected value type at ${path || ''}`, value) } } + + function pushField(path: FieldPath, value: FieldValue) { + const previousValue = fields.get(path) + if (Array.isArray(previousValue)) { + previousValue.push(value) + } else if (previousValue !== undefined) { + fields.set(path, [previousValue, value]) + } else { + fields.set(path, value) + } + } } diff --git a/developer-extension/src/panel/uiUtils.ts b/developer-extension/src/panel/uiUtils.ts index a4969c089c..2302b56ce8 100644 --- a/developer-extension/src/panel/uiUtils.ts +++ b/developer-extension/src/panel/uiUtils.ts @@ -3,10 +3,18 @@ import { rem } from '@mantine/core' export const BORDER_RADIUS = 8 +export function separatorBorder(theme: MantineTheme) { + return `1px solid ${borderColor(theme)}` +} + /** * Returns the same CSS border as the mantine TabsList */ export function tabsListBorder(theme: MantineTheme) { // https://github.com/mantinedev/mantine/blob/cf0f85faec56615ea5fbd7813e83bac60dbaefb7/src/mantine-core/src/Tabs/TabsList/TabsList.styles.ts#L25-L26 - return `${rem(2)} solid ${theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3]}` + return `${rem(2)} solid ${borderColor(theme)}` +} + +function borderColor(theme: MantineTheme) { + return theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[3] }