-
Notifications
You must be signed in to change notification settings - Fork 142
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ [extension] add columns to the event list (#2372)
* ♻️ 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
1 parent
bd5aa77
commit 26d48f7
Showing
12 changed files
with
783 additions
and
68 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
188 changes: 188 additions & 0 deletions
188
developer-extension/src/panel/components/tabs/eventsTab/columnDrag.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}, | ||
}) | ||
} |
40 changes: 40 additions & 0 deletions
40
developer-extension/src/panel/components/tabs/eventsTab/columnUtils.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
121
developer-extension/src/panel/components/tabs/eventsTab/drag.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} | ||
} | ||
} |
Oops, something went wrong.