Skip to content

Commit c938be7

Browse files
authored
feat: table interactions (lightdash#2977)
* feat: table row selection on hover * feat: WIP/POC of link cell * feat: open cell context menu on click w/ better positioning * feat: add cell selection * fix: selected cell border styles * feat: add cursor pointer to body cell * chore: rename components * chore: extract RichBodyCell component from TableBody * feat: add "visit link" to cell context menu * copy: go to link text * fix: table scroll overflow with context menu open * fix: add missing key to context menu wrapper * feat: add tracking event for go to link * fix: Popper2 scroll inside overflow: auto * fix: table interactivity * fix: small table overflow with action menu
1 parent 9fba3ff commit c938be7

File tree

11 files changed

+265
-91
lines changed

11 files changed

+265
-91
lines changed
Lines changed: 113 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,125 @@
1-
import { Menu, MenuItem } from '@blueprintjs/core';
2-
import { ContextMenu2 } from '@blueprintjs/popover2';
3-
import { isField, isFilterableField, ResultRow } from '@lightdash/common';
4-
import React from 'react';
1+
import { Menu, Position } from '@blueprintjs/core';
2+
import { MenuItem2, Popover2 } from '@blueprintjs/popover2';
3+
import {
4+
Field,
5+
isField,
6+
isFilterableField,
7+
ResultRow,
8+
TableCalculation,
9+
} from '@lightdash/common';
10+
import { Cell } from '@tanstack/react-table';
11+
import { cloneElement, FC, isValidElement } from 'react';
512
import { useFilters } from '../../../hooks/useFilters';
613
import { useTracking } from '../../../providers/TrackingProvider';
714
import { EventName } from '../../../types/Events';
15+
import { isUrl } from '../../common/Table/ScrollableTable/RichBodyCell';
816
import { CellContextMenuProps, TableColumn } from '../../common/Table/types';
917
import { useUnderlyingDataContext } from '../../UnderlyingData/UnderlyingDataProvider';
1018

11-
export const CellContextMenu: React.FC<
12-
CellContextMenuProps & { isEditMode: boolean }
13-
> = ({ isEditMode, children, cell }) => {
19+
interface ContextMenuProps {
20+
cell: Cell<ResultRow>;
21+
item?: Field | TableCalculation;
22+
meta: TableColumn['meta'];
23+
isEditMode: boolean;
24+
}
25+
26+
const ContextMenu: FC<ContextMenuProps> = ({
27+
meta,
28+
item,
29+
cell,
30+
isEditMode,
31+
}) => {
1432
const { addFilter } = useFilters();
33+
const { viewData } = useUnderlyingDataContext();
34+
const { track } = useTracking();
35+
36+
const value: ResultRow[0]['value'] = cell.getValue()?.value || {};
37+
38+
return (
39+
<Menu>
40+
{value.raw && isUrl(value.raw) && (
41+
<MenuItem2
42+
icon="link"
43+
text="Go to link"
44+
onClick={() => {
45+
track({
46+
name: EventName.GO_TO_LINK_CLICKED,
47+
});
48+
window.open(value.raw, '_blank');
49+
}}
50+
/>
51+
)}
52+
53+
<MenuItem2
54+
text="View underlying data"
55+
icon="layers"
56+
onClick={() => {
57+
viewData(value, meta, cell.row.original || {});
58+
}}
59+
/>
60+
61+
{isEditMode && isField(item) && isFilterableField(item) && (
62+
<MenuItem2
63+
icon="filter"
64+
text={`Filter by "${value.formatted}"`}
65+
onClick={() => {
66+
track({
67+
name: EventName.ADD_FILTER_CLICKED,
68+
});
69+
addFilter(
70+
item,
71+
value.raw === undefined ? null : value.raw,
72+
true,
73+
);
74+
}}
75+
/>
76+
)}
77+
</Menu>
78+
);
79+
};
80+
81+
const CellContextMenu: FC<
82+
CellContextMenuProps & {
83+
isEditMode: boolean;
84+
}
85+
> = ({ isEditMode, boundaryElement, children, cell, onOpen, onClose }) => {
1586
const meta = cell.column.columnDef.meta as TableColumn['meta'];
1687
const item = meta?.item;
17-
const { track } = useTracking();
18-
const { viewData } = useUnderlyingDataContext();
1988

20-
if (item) {
21-
const value: ResultRow[0]['value'] = cell.getValue()?.value || {};
22-
return (
23-
<ContextMenu2
24-
content={
25-
<Menu>
26-
<MenuItem
27-
text={`View underlying data`}
28-
icon={'layers'}
29-
onClick={(e) => {
30-
viewData(value, meta, cell.row.original || {});
31-
}}
32-
/>
33-
{isEditMode &&
34-
isField(item) &&
35-
isFilterableField(item) && (
36-
<MenuItem
37-
icon={'filter'}
38-
text={`Filter by "${value.formatted}"`}
39-
onClick={() => {
40-
track({
41-
name: EventName.ADD_FILTER_CLICKED,
42-
});
43-
addFilter(
44-
item,
45-
value.raw === undefined
46-
? null
47-
: value.raw,
48-
true,
49-
);
50-
}}
51-
/>
52-
)}
53-
</Menu>
54-
}
55-
>
56-
{children}
57-
</ContextMenu2>
58-
);
89+
if (!item || !boundaryElement) {
90+
return <>{children}</>;
5991
}
60-
return <>{children}</>;
92+
93+
return (
94+
<Popover2
95+
minimal
96+
lazy
97+
position={Position.BOTTOM_RIGHT}
98+
boundary={boundaryElement}
99+
content={
100+
<ContextMenu
101+
cell={cell}
102+
item={item}
103+
meta={meta}
104+
isEditMode={isEditMode}
105+
/>
106+
}
107+
renderTarget={({ ref, ...targetProps }) => {
108+
if (isValidElement(children)) {
109+
return cloneElement(children, {
110+
ref,
111+
...targetProps,
112+
});
113+
} else {
114+
throw new Error(
115+
'CellContextMenu children must be a valid React element',
116+
);
117+
}
118+
}}
119+
onOpening={onOpen}
120+
onClosing={onClose}
121+
/>
122+
);
61123
};
124+
125+
export default CellContextMenu;

packages/frontend/src/components/Explorer/ResultsCard/ExplorerResults.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { SectionName } from '../../../types/Events';
1010
import Table from '../../common/Table';
1111
import { HeaderProps, TableColumn } from '../../common/Table/types';
1212
import TableCalculationHeaderButton from '../../TableCalculationHeaderButton';
13-
import { CellContextMenu } from './CellContextMenu';
13+
import CellContextMenu from './CellContextMenu';
1414
import ColumnHeaderContextMenu from './ColumnHeaderContextMenu';
1515
import {
1616
EmptyStateExploreLoading,
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { ResultRow } from '@lightdash/common';
2+
import { Cell } from '@tanstack/react-table';
3+
import { FC } from 'react';
4+
import styled from 'styled-components';
5+
6+
interface RichBodyCellProps {
7+
cell: Cell<ResultRow>;
8+
}
9+
10+
const Link = styled.a`
11+
:hover {
12+
text-decoration: none;
13+
}
14+
`;
15+
16+
export const isUrl = (value: string) => {
17+
return (
18+
value &&
19+
typeof value === 'string' &&
20+
(value.startsWith('http://') || value.startsWith('https://'))
21+
);
22+
};
23+
24+
const RichBodyCell: FC<RichBodyCellProps> = ({ children, cell }) => {
25+
const rawValue = cell.getValue()?.value?.raw;
26+
27+
if (isUrl(rawValue)) {
28+
return <Link>{children}</Link>;
29+
} else {
30+
return <>{children}</>;
31+
}
32+
};
33+
34+
export default RichBodyCell;

packages/frontend/src/components/common/Table/ScrollableTable/TableBody.tsx

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,55 @@
11
import { isNumericItem } from '@lightdash/common';
22
import { flexRender } from '@tanstack/react-table';
3-
import React from 'react';
3+
import { FC, Fragment, useState } from 'react';
44
import { BodyCell } from '../Table.styles';
55
import { useTableContext } from '../TableProvider';
66
import { TableColumn } from '../types';
7+
import RichBodyCell from './RichBodyCell';
8+
9+
const TableBody: FC = () => {
10+
const { table, cellContextMenu, tableWrapperRef, setIsScrollable } =
11+
useTableContext();
12+
const CellContextMenu = cellContextMenu || Fragment;
13+
const [selectedCell, setSelectedCell] = useState<string>();
14+
15+
const handleCellSelect = (cellId: string | undefined) => {
16+
setIsScrollable(!cellId);
17+
setSelectedCell(cellId);
18+
};
719

8-
const TableBody = () => {
9-
const { table, cellContextMenu } = useTableContext();
10-
const CellContextMenu = cellContextMenu || React.Fragment;
1120
return (
1221
<tbody>
1322
{table.getRowModel().rows.map((row, rowIndex) => (
1423
<tr key={row.id}>
1524
{row.getVisibleCells().map((cell) => {
1625
const meta = cell.column.columnDef
1726
.meta as TableColumn['meta'];
27+
1828
return (
19-
<BodyCell
29+
<CellContextMenu
2030
key={cell.id}
21-
$rowIndex={rowIndex}
22-
$isNaN={
23-
!meta?.item || !isNumericItem(meta.item)
24-
}
31+
cell={cell}
32+
boundaryElement={tableWrapperRef.current}
33+
onOpen={() => handleCellSelect(cell.id)}
34+
onClose={() => handleCellSelect(undefined)}
2535
>
26-
<CellContextMenu cell={cell}>
27-
{flexRender(
28-
cell.column.columnDef.cell,
29-
cell.getContext(),
30-
)}
31-
</CellContextMenu>
32-
</BodyCell>
36+
<BodyCell
37+
$rowIndex={rowIndex}
38+
$isSelected={cell.id === selectedCell}
39+
$isInteractive={!!cellContextMenu}
40+
$hasData={!!meta?.item}
41+
$isNaN={
42+
!meta?.item || !isNumericItem(meta.item)
43+
}
44+
>
45+
<RichBodyCell cell={cell}>
46+
{flexRender(
47+
cell.column.columnDef.cell,
48+
cell.getContext(),
49+
)}
50+
</RichBodyCell>
51+
</BodyCell>
52+
</CellContextMenu>
3353
);
3454
})}
3555
</tr>

packages/frontend/src/components/common/Table/ScrollableTable/index.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
import React from 'react';
21
import { Table, TableScrollableWrapper } from '../Table.styles';
32
import { useTableContext } from '../TableProvider';
43
import TableBody from './TableBody';
54
import TableFooter from './TableFooter';
65
import TableHeader from './TableHeader';
76

87
const ScrollableTable = () => {
9-
const { footer } = useTableContext();
8+
const { footer, setTableWrapperRef, isScrollable } = useTableContext();
9+
1010
return (
11-
<TableScrollableWrapper>
11+
<TableScrollableWrapper
12+
ref={setTableWrapperRef}
13+
$isScrollable={isScrollable}
14+
>
1215
<Table bordered condensed showFooter={!!footer?.show}>
1316
<TableHeader />
1417
<TableBody />

packages/frontend/src/components/common/Table/Table.styles.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,13 @@ export const TableContainer = styled.div`
88
display: flex;
99
flex-direction: column;
1010
`;
11-
export const TableScrollableWrapper = styled.div`
12-
overflow: auto;
11+
12+
interface TableScrollableProps {
13+
$isScrollable: boolean;
14+
}
15+
16+
export const TableScrollableWrapper = styled.div<TableScrollableProps>`
17+
overflow: ${({ $isScrollable }) => ($isScrollable ? 'auto' : 'hidden')};
1318
min-height: 90px;
1419
`;
1520

@@ -38,13 +43,13 @@ export const Table = styled(HTMLTable)<{ showFooter: boolean }>`
3843
}
3944
}
4045
41-
tbody tr:first-child {
42-
td:first-child {
43-
box-shadow: none !important;
46+
tbody tr {
47+
:nth-child(even) {
48+
background-color: ${Colors.LIGHT_GRAY5};
4449
}
4550
46-
td {
47-
box-shadow: inset 1px 0 0 0 rgb(17 20 24 / 15%) !important;
51+
:hover {
52+
background: ${Colors.LIGHT_GRAY3};
4853
}
4954
}
5055
@@ -80,13 +85,29 @@ const CellStyles = css<{ $isNaN: boolean }>`
8085
text-align: ${({ $isNaN }) => ($isNaN ? 'left' : 'right')} !important;
8186
`;
8287

83-
export const BodyCell = styled.td<{ $isNaN: boolean; $rowIndex: number }>`
88+
export const BodyCell = styled.td<{
89+
$isNaN: boolean;
90+
$rowIndex: number;
91+
$isSelected: boolean;
92+
$isInteractive: boolean;
93+
$hasData: boolean;
94+
}>`
8495
${CellStyles}
85-
${({ $rowIndex }) =>
86-
$rowIndex % 2 &&
87-
`
88-
background-color: ${Colors.LIGHT_GRAY5}
89-
`}
96+
97+
${({ $isInteractive, $hasData }) =>
98+
$isInteractive && $hasData
99+
? `
100+
cursor: pointer;
101+
`
102+
: ''}
103+
104+
${({ $isInteractive, $isSelected, $hasData }) =>
105+
$isInteractive && $isSelected && $hasData
106+
? `
107+
box-shadow: inset 0 0 0 1px #4170CB !important;
108+
background-color: #ECF6FE;
109+
`
110+
: ''}
90111
`;
91112

92113
export const FooterCell = styled.th<{ $isNaN: boolean }>`

0 commit comments

Comments
 (0)