Skip to content

Commit

Permalink
✨ [extension] add columns to the event list (#2372)
Browse files Browse the repository at this point in the history
* ♻️ ditch mantine Table for a proper CSS grid

* ✨ add columns to the event list

* 👌🐛 replace the Grid with a Table

This fixes wrapping issues and reduce complexity (the CSS grid syntax is
a bit confusing).

The UI isn't quite how I like it, I have ideas on how to improve it but
it'll wait.

* ♻️ refactor columns operations

* 👌✨ add a button to remove columns

* ✏️🐛 the monorepo TS configuration prevents us from using iterators
  • Loading branch information
BenoitZugmeyer authored Sep 15, 2023
1 parent bd5aa77 commit 26d48f7
Show file tree
Hide file tree
Showing 12 changed files with 783 additions and 68 deletions.
8 changes: 6 additions & 2 deletions developer-extension/src/panel/components/panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useSettings } from '../hooks/useSettings'
import { DEFAULT_PANEL_TAB, PanelTabs } from '../../common/constants'
import { SettingsTab } from './tabs/settingsTab'
import { InfosTab } from './tabs/infosTab'
import { EventsTab } from './tabs/eventsTab'
import { EventsTab, DEFAULT_COLUMNS } from './tabs/eventsTab'
import { ReplayTab } from './tabs/replayTab'

export function Panel() {
Expand All @@ -21,6 +21,8 @@ export function Panel() {

const { events, filters, setFilters, clear, facetRegistry } = useEvents(settings)

const [columns, setColumns] = useState(DEFAULT_COLUMNS)

const [activeTab, setActiveTab] = useState<string | null>(DEFAULT_PANEL_TAB)
function updateActiveTab(activeTab: string | null) {
setActiveTab(activeTab)
Expand Down Expand Up @@ -60,7 +62,9 @@ export function Panel() {
events={events}
facetRegistry={facetRegistry}
filters={filters}
onFiltered={setFilters}
onFiltersChange={setFilters}
columns={columns}
onColumnsChange={setColumns}
clear={clear}
/>
</Tabs.Panel>
Expand Down
188 changes: 188 additions & 0 deletions developer-extension/src/panel/components/tabs/eventsTab/columnDrag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import type { RefObject } from 'react'
import React, { useState, useEffect } from 'react'
import { Box, Text } from '@mantine/core'
import { BORDER_RADIUS } from '../../../uiUtils'
import type { Coordinates } from './drag'
import { initDrag } from './drag'
import type { EventListColumn } from './columnUtils'
import { moveColumn, removeColumn, getColumnTitle } from './columnUtils'

/** Horizontal padding used by the Mantine Table in pixels */
const HORIZONTAL_PADDING = 10

/** Vertical padding used by the Mantine Table in pixels */
const VERTICAL_PADDING = 7

/** Number of pixel to determine if the cursor is close enough of a position to trigger an action */
const ACTION_DISTANCE_THRESHOLD = 20

export function ColumnDrag({
headerRowRef,
columns,
onColumnsChange,
}: {
headerRowRef: RefObject<HTMLDivElement>
columns: EventListColumn[]
onColumnsChange: (columns: EventListColumn[]) => void
}) {
const [drag, setDrag] = useState<DragState | null>(null)

useEffect(() => {
if (columns.length > 1) {
const { stop } = initColumnDrag(headerRowRef.current!, setDrag, columns, onColumnsChange)
return stop
}
}, [columns])

return drag && <DragGhost drag={drag} />
}

function DragGhost({ drag }: { drag: DragState }) {
return (
<Box
sx={{
position: 'fixed',
opacity: 0.5,
borderRadius: BORDER_RADIUS,

top: drag.targetRect.top,
height: drag.targetRect.height,
transform: 'translateX(-50%)',
left: drag.position.x,
background: 'grey',

paddingTop: VERTICAL_PADDING,
paddingBottom: VERTICAL_PADDING,
paddingLeft: HORIZONTAL_PADDING,
paddingRight: HORIZONTAL_PADDING,
cursor: 'grabbing',

...(drag.action?.type === 'insert' && {
width: 0,
left: drag.action.place.xPosition,
background: 'green',
color: 'transparent',
paddingRight: 3,
paddingLeft: 3,
}),

...(drag.action?.type === 'delete' && {
top: drag.targetRect.y + (drag.position.y - drag.startPosition.y),
background: 'red',
}),
}}
>
<Text weight="bold">{getColumnTitle(drag.column)}</Text>
</Box>
)
}

function getClosestCell(target: HTMLElement) {
if (target.closest('button, .mantine-Popover-dropdown')) {
return null
}
return target.closest('[data-header-cell]')
}

interface DragState {
targetRect: DOMRect
startPosition: Coordinates
position: Coordinates
action?: DragAction
moved: boolean
insertPlaces: Place[]
column: EventListColumn
}

interface Place {
index: number
xPosition: number
}

type DragAction = { type: 'delete' } | { type: 'insert'; place: Place }

function initColumnDrag(
target: HTMLElement,
onColumnDragStateChanges: (state: DragState | null) => void,
columns: EventListColumn[],
onColumnsChange: (columns: EventListColumn[]) => void
) {
let state: DragState | null = null

return initDrag({
target,

onStart({ target, position }) {
const targetCell = getClosestCell(target)
if (!targetCell) {
return false
}
const siblings = Array.from(targetCell.parentElement!.querySelectorAll(':scope > [data-header-cell]'))
const columnIndex = siblings.indexOf(targetCell)

state = {
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,
column: columns[columnIndex],
}
onColumnDragStateChanges(state)
},

onMove({ position }) {
if (!state) {
return
}
let action: DragAction | undefined
if (Math.abs(state.startPosition.y - position.y) > ACTION_DISTANCE_THRESHOLD) {
action = { type: 'delete' }
} else {
const insertPlace = state.insertPlaces.find(
({ xPosition }) => Math.abs(position.x - xPosition) < ACTION_DISTANCE_THRESHOLD
)
if (insertPlace) {
action = { type: 'insert', place: insertPlace }
}
}

state = { ...state, action, position, moved: true }
onColumnDragStateChanges(state)
},

onDrop() {
if (!state) {
return
}

if (state.action) {
switch (state.action.type) {
case 'delete':
onColumnsChange(removeColumn(columns, state.column))
break
case 'insert':
onColumnsChange(moveColumn(columns, state.column, state.action.place.index))
break
}
}

state = null
onColumnDragStateChanges(state)
},

onAbort() {
state = null
onColumnDragStateChanges(state)
},
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
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 addColumn(columns: EventListColumn[], columnToAdd: EventListColumn) {
return columns.concat(columnToAdd)
}

export function removeColumn(columns: EventListColumn[], columnToRemove: EventListColumn) {
return columns.filter((column) => columnToRemove !== column)
}

export function moveColumn(columns: EventListColumn[], columnToMove: EventListColumn, index: number) {
const newColumns = removeColumn(columns, columnToMove)
newColumns.splice(index, 0, columnToMove)
return newColumns
}

export function getColumnTitle(column: EventListColumn) {
return column.type === 'date'
? 'Date'
: column.type === 'description'
? 'Description'
: column.type === 'type'
? 'Type'
: column.path
}
121 changes: 121 additions & 0 deletions developer-extension/src/panel/components/tabs/eventsTab/drag.ts
Original file line number Diff line number Diff line change
@@ -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]
* | \______________
* | \
* <returns true> <returns false>
* | ____ |
* | / \ (end)
* [onMove] )
* | \ \____/
* | \______________
* | \
* <drop in the window> |
* | <stop() called or drop out of the window>
* | |
* [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 }
}
}
}
Loading

0 comments on commit 26d48f7

Please sign in to comment.