Skip to content

Commit 6d6225f

Browse files
author
Amelia Wattenberger
committed
add the ability to edit cell data
1 parent 45cbe1c commit 6d6225f

File tree

8 files changed

+562
-134
lines changed

8 files changed

+562
-134
lines changed

example/index.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ import debounce from 'lodash/debounce';
99
import exampleData from './data';
1010

1111
const App = () => {
12-
const [data, setData] = React.useState([]);
12+
const [data, setData] = React.useState<any[]>([]);
1313
const [isLoading, setIsLoading] = React.useState(true);
14-
const [dataUrl, setDataUrl] = React.useState('');
14+
const [dataUrl, setDataUrl] = React.useState('https://raw.githubusercontent.com/the-pudding/data/master/boybands/boys.csv');
1515
const [overrideDataUrl, setOverrideDataUrl] = React.useState('');
1616
const [localOverrideDataUrl, setLocalOverrideDataUrl] = React.useState('');
1717

@@ -91,7 +91,16 @@ const App = () => {
9191
)}
9292
</div>
9393

94-
<div style={{ flex: '1 1 0%' }}>{!isLoading && <Grid data={data} />}</div>
94+
<div style={{ flex: '1 1 0%' }}>{!isLoading && (
95+
<Grid
96+
data={data}
97+
isEditable
98+
onEdit={(newData: any[]) => {
99+
console.log(newData)
100+
setData(newData);
101+
}}
102+
/>
103+
)}</div>
95104
</div>
96105
);
97106
};

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@
8585
"react-is": "^17.0.1",
8686
"rollup-plugin-postcss": "^4.0.0",
8787
"size-limit": "^4.10.1",
88-
"tailwindcss": "^2.0.3",
88+
"tailwindcss": "^3.0.15",
8989
"tsdx": "^0.14.1",
9090
"tslib": "^2.1.0",
9191
"typescript": "^4.3.5"

src/components/cell.tsx

Lines changed: 82 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import React from 'react';
1+
import React, { useEffect } from 'react';
22
import { areEqual } from 'react-window';
33
import tw, { TwStyle } from 'twin.macro';
44
import anchorme from 'anchorme';
55
import { cellTypeMap } from '../store';
66
import { DashIcon, DiffModifiedIcon, PlusIcon } from '@primer/octicons-react';
77
import DOMPurify from 'dompurify';
8+
import { EditableCell } from './editable-cell';
89

910
interface CellProps {
1011
type: string;
@@ -19,9 +20,13 @@ interface CellProps {
1920
isFirstColumn?: boolean;
2021
hasStatusIndicator?: boolean;
2122
background?: string;
23+
isEditable: boolean;
24+
onCellChange?: (value: any) => void;
25+
isFocused: boolean;
26+
onFocusChange: (value: [number, number] | null) => void;
2227
onMouseEnter?: Function;
2328
}
24-
export const Cell = React.memo(function(props: CellProps) {
29+
export const Cell = React.memo(function (props: CellProps) {
2530
const {
2631
type,
2732
value,
@@ -32,36 +37,45 @@ export const Cell = React.memo(function(props: CellProps) {
3237
isFirstColumn,
3338
isNearRightEdge,
3439
isNearBottomEdge,
40+
isEditable,
41+
onCellChange,
42+
isFocused,
43+
onFocusChange,
3544
background,
3645
style = {},
37-
onMouseEnter = () => {},
46+
onMouseEnter = () => { },
3847
} = props;
3948

4049
// @ts-ignore
4150
const cellInfo = cellTypeMap[type];
42-
if (!cellInfo) return null;
4351

44-
const { cell: CellComponent } = cellInfo;
52+
const { cell: CellComponent } = cellInfo || {}
4553

4654
const displayValue = formattedValue || value;
4755
const isLongValue = (displayValue || '').length > 23;
48-
const stringWithLinks = displayValue
49-
? React.useMemo(
50-
() =>
51-
DOMPurify.sanitize(
52-
anchorme({
53-
input: displayValue + '',
54-
options: {
55-
attributes: {
56-
target: '_blank',
57-
rel: 'noopener',
58-
},
59-
},
60-
})
61-
),
62-
[value]
56+
const stringWithLinks = React.useMemo(
57+
() => displayValue ? (
58+
DOMPurify.sanitize(
59+
anchorme({
60+
input: displayValue + '',
61+
options: {
62+
attributes: {
63+
target: '_blank',
64+
rel: 'noopener',
65+
},
66+
},
67+
})
6368
)
64-
: '';
69+
) : "",
70+
[value]
71+
)
72+
73+
useEffect(() => {
74+
if (!isFocused) return
75+
onMouseEnter()
76+
}, [isFocused])
77+
78+
if (!cellInfo) return null;
6579

6680
const StatusIcon =
6781
isFirstColumn &&
@@ -86,54 +100,66 @@ export const Cell = React.memo(function(props: CellProps) {
86100
<div
87101
className="cell group"
88102
css={[
89-
tw`flex flex-none items-center px-4 border-b border-r`,
90-
91-
typeof value === 'undefined' ||
92-
(Number.isNaN(value) && tw`text-gray-300`),
103+
tw`flex border-b border-r`,
93104
status === 'new' && tw`border-green-200`,
94105
status === 'old' && tw`border-pink-200`,
95106
status === 'modified' && tw`border-yellow-200`,
96-
!status && tw`border-gray-200`,
107+
!status && tw`border-gray-200`
97108
]}
98-
onMouseEnter={() => onMouseEnter()}
99109
style={{
100110
...style,
101111
background: background || '#fff',
102-
}}
103-
>
104-
{isFirstColumn && (
105-
<div css={[tw`w-6 flex-none`, statusColor]}>
106-
{StatusIcon && <StatusIcon />}
107-
</div>
108-
)}
109-
110-
<CellComponent
111-
value={value}
112-
formattedValue={stringWithLinks}
113-
rawValue={rawValue}
114-
categoryColor={categoryColor}
115-
/>
116-
117-
{isLongValue && (
112+
}}>
113+
<EditableCell
114+
type={type}
115+
value={rawValue}
116+
isEditable={isEditable}
117+
onChange={onCellChange}
118+
isFocused={isFocused}
119+
onFocusChange={onFocusChange}>
118120
<div
119-
className="cell__long-value"
120121
css={[
121-
tw` absolute p-4 py-2 bg-white opacity-0 group-hover:opacity-100 z-30 border border-gray-200 shadow-md pointer-events-none`,
122-
isNearBottomEdge ? tw`bottom-0` : tw`top-0`,
123-
isNearRightEdge ? tw`right-0` : tw`left-0`,
122+
tw`w-full h-full flex flex-none items-center px-4`,
123+
typeof value === 'undefined' ||
124+
(Number.isNaN(value) && tw`text-gray-300`),
124125
]}
125-
style={{
126-
width: 'max-content',
127-
maxWidth: '27em',
128-
}}
129-
title={rawValue}
126+
onMouseEnter={() => onMouseEnter()}
130127
>
131-
<div
132-
tw="line-clamp-9"
133-
dangerouslySetInnerHTML={{ __html: stringWithLinks }}
128+
{isFirstColumn && (
129+
<div css={[tw`w-6 flex-none`, statusColor]}>
130+
{StatusIcon && <StatusIcon />}
131+
</div>
132+
)}
133+
134+
<CellComponent
135+
value={value}
136+
formattedValue={stringWithLinks}
137+
rawValue={rawValue}
138+
categoryColor={categoryColor}
134139
/>
140+
141+
{isLongValue && (
142+
<div
143+
className="cell__long-value"
144+
css={[
145+
tw` absolute p-4 py-2 bg-white opacity-0 group-hover:opacity-100 z-30 border border-gray-200 shadow-md pointer-events-none`,
146+
isNearBottomEdge ? tw`bottom-0` : tw`top-0`,
147+
isNearRightEdge ? tw`right-0` : tw`left-0`,
148+
]}
149+
style={{
150+
width: 'max-content',
151+
maxWidth: '27em',
152+
}}
153+
title={rawValue}
154+
>
155+
<div
156+
tw="line-clamp-9"
157+
dangerouslySetInnerHTML={{ __html: stringWithLinks }}
158+
/>
159+
</div>
160+
)}
135161
</div>
136-
)}
162+
</EditableCell>
137163
</div>
138164
);
139165
}, areEqual);

src/components/editable-cell.tsx

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import React, { useEffect, useRef } from 'react';
2+
import { areEqual } from 'react-window';
3+
import tw from 'twin.macro';
4+
5+
interface EditableCellProps {
6+
type: string;
7+
value: any;
8+
isEditable: boolean;
9+
onChange?: (value: any) => void;
10+
isFocused: boolean;
11+
onFocusChange?: (value: [number, number] | null) => void;
12+
children: any;
13+
}
14+
export const EditableCell = React.memo(function (props: EditableCellProps) {
15+
const {
16+
// type,
17+
value,
18+
isEditable,
19+
onChange,
20+
isFocused,
21+
onFocusChange,
22+
children,
23+
} = props;
24+
25+
const [isEditing, setIsEditing] = React.useState(false);
26+
const [editedValue, setEditedValue] = React.useState(value);
27+
const cellElement = useRef<HTMLDivElement>(null);
28+
29+
useEffect(() => {
30+
setEditedValue(value);
31+
}, [value]);
32+
33+
const onSubmit = () => {
34+
onFocusChange?.([1, 0]);
35+
if (onChange) onChange(editedValue);
36+
}
37+
38+
useEffect(() => {
39+
if (!isFocused) {
40+
setIsEditing(false);
41+
setEditedValue(value);
42+
return
43+
}
44+
45+
const onKeyDown = (e: KeyboardEvent) => {
46+
let diff = cellDiffs[e.key]
47+
if (diff) {
48+
if (e.metaKey) {
49+
// scroll to top/bottom
50+
diff = diff.map(d => d ? Infinity * d : d) as [number, number]
51+
}
52+
onFocusChange?.(diff)
53+
e.stopPropagation()
54+
e.preventDefault()
55+
} else if (e.key === 'Enter') {
56+
setIsEditing(true);
57+
} else if (e.key === 'Escape') {
58+
onFocusChange?.(null)
59+
}
60+
}
61+
window.addEventListener('keydown', onKeyDown);
62+
return () => {
63+
window.removeEventListener('keydown', onKeyDown);
64+
}
65+
}, [isFocused])
66+
67+
if (!isEditable) return children
68+
69+
return (
70+
<div
71+
ref={cellElement}
72+
css={[
73+
tw`w-full h-full flex items-center cursor-cell border-[3px] border-transparent`,
74+
isFocused && tw`border-indigo-500`,
75+
]}
76+
onClick={() => onFocusChange?.([0, 0])}
77+
onDoubleClick={() => setIsEditing(true)}
78+
>
79+
{isEditing ? (
80+
<input
81+
type="text"
82+
autoFocus
83+
onFocus={e => {
84+
e.target.select();
85+
}}
86+
css={[
87+
tw`w-full h-full py-2 px-4 font-mono text-sm focus:outline-none bg-transparent`,
88+
]}
89+
value={editedValue}
90+
onChange={e => setEditedValue(e.target.value)}
91+
onKeyDown={e => {
92+
if (e.key === 'Enter') {
93+
onSubmit()
94+
} else if (e.key === 'Escape') {
95+
onFocusChange?.(null)
96+
}
97+
if (cellDiffs[e.key]) {
98+
e.stopPropagation()
99+
}
100+
}}
101+
onBlur={onSubmit}
102+
/>
103+
) : (
104+
children
105+
)}
106+
</div>
107+
);
108+
}, areEqual);
109+
110+
const cellDiffs = {
111+
"ArrowUp": [-1, 0],
112+
"ArrowDown": [1, 0],
113+
"ArrowLeft": [0, -1],
114+
"ArrowRight": [0, 1],
115+
} as Record<string, [number, number]>

0 commit comments

Comments
 (0)