Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: [SC-43819] draggable table rows #987

Merged
merged 25 commits into from
Jan 10, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 156 additions & 11 deletions src/components/Table/GridTable.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
simpleHeader,
SimpleHeaderAndData,
useGridTableApi,
insertAtIndex,
} from "src/components/index";
import { Css, Palette } from "src/Css";
import { useComputed } from "src/hooks";
Expand Down Expand Up @@ -826,7 +827,7 @@ export function CustomEmptyCell() {
);
}

function makeNestedRows(repeat: number = 1): GridDataRow<NestedRow>[] {
function makeNestedRows(repeat: number = 1, draggable: boolean = false): GridDataRow<NestedRow>[] {
let parentId = 0;
return zeroTo(repeat).flatMap((i) => {
// Make three unique parent ids for this iteration
Expand All @@ -837,36 +838,39 @@ function makeNestedRows(repeat: number = 1): GridDataRow<NestedRow>[] {
const rows: GridDataRow<NestedRow>[] = [
// a parent w/ two children, 1st child has 2 grandchild, 2nd child has 1 grandchild
{
...{ kind: "parent", id: p1, data: { name: `parent ${prefix}1` } },
...{ kind: "parent", id: p1, data: { name: `parent ${prefix}1` }, draggable },
children: [
{
...{ kind: "child", id: `${p1}c1`, data: { name: `child ${prefix}p1c1` } },
...{ kind: "child", id: `${p1}c1`, data: { name: `child ${prefix}p1c1` }, draggable },
children: [
{
kind: "grandChild",
id: `${p1}c1g1`,
data: { name: `grandchild ${prefix}p1c1g1` + " foo".repeat(20) },
draggable,
},
{ kind: "grandChild", id: `${p1}c1g2`, data: { name: `grandchild ${prefix}p1c1g2` } },
{ kind: "grandChild", id: `${p1}c1g2`, data: { name: `grandchild ${prefix}p1c1g2` }, draggable },
],
},
{
...{ kind: "child", id: `${p1}c2`, data: { name: `child ${prefix}p1c2` } },
children: [{ kind: "grandChild", id: `${p1}c2g1`, data: { name: `grandchild ${prefix}p1c2g1` } }],
...{ kind: "child", id: `${p1}c2`, data: { name: `child ${prefix}p1c2` }, draggable },
children: [
{ kind: "grandChild", id: `${p1}c2g1`, data: { name: `grandchild ${prefix}p1c2g1` }, draggable },
],
},
// Put this "grandchild" in the 2nd level to show heterogeneous levels
{ kind: "grandChild", id: `${p1}g1`, data: { name: `grandchild ${prefix}p1g1` } },
{ kind: "grandChild", id: `${p1}g1`, data: { name: `grandchild ${prefix}p1g1` }, draggable },
// Put this "kind" into the 2nd level to show it doesn't have to be a card
{ kind: "add", id: `${p1}add`, pin: "last", data: {} },
{ kind: "add", id: `${p1}add`, pin: "last", data: {}, draggable },
],
},
// a parent with just a child
{
...{ kind: "parent", id: p2, data: { name: `parent ${prefix}2` } },
children: [{ kind: "child", id: `${p2}c1`, data: { name: `child ${prefix}p2c1` } }],
...{ kind: "parent", id: p2, data: { name: `parent ${prefix}2` }, draggable },
children: [{ kind: "child", id: `${p2}c1`, data: { name: `child ${prefix}p2c1` }, draggable }],
},
// a parent with no children
{ kind: "parent", id: p3, data: { name: `parent ${prefix}3` } },
{ kind: "parent", id: p3, data: { name: `parent ${prefix}3` }, draggable },
];
return rows;
});
Expand Down Expand Up @@ -1849,3 +1853,144 @@ export function Headers() {
</div>
);
}

/**
* Shows how drag & drop reordering can be implemented with GridTable drag events
*/
export function DraggableRows() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Praise: I popped this open in storybook and it looks slick!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the only thing this doesn't do is scroll the page/table when I get to the top and bottom, but otherwise it seems pretty functionally complete? Very nice! Pretty great that it's just native events too, and not bringing in a new library. Kinda assumed we'd just need that, but this looks good!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was scrolling for me when dragging to top/bottom, thought there seems to be a small window of where you need to hold the row for it to work 🤔

praise: Looks great @bsholmes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah the scrolling behavior isn't ideal, and is relying on the dataTransfer.effectAllowed = "move" stuff so isn't tweakable. We may need to implement our own scrolling behavior, at least on mobile.

const nameColumn: GridColumn<Row> = {
header: "Name",
data: ({ name }) => ({ content: <div>{name}</div>, sortValue: name }),
};

const actionColumn: GridColumn<Row> = {
header: "Action",
data: () => <div>Actions</div>,
clientSideSort: false,
};

let rowArray: GridDataRow<Row>[] = new Array(26).fill(0);
rowArray = rowArray.map((elem, idx) => ({
kind: "data",
id: "" + (idx + 1),
order: idx + 1,
data: { name: "" + (idx + 1), value: idx + 1 },
draggable: true,
}));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bsholmes I think one of the things that @bdow noticed in the demo is that the data-render HTML attributes of the row elements were going way up; we use those to make sure React is not completely re-rendering every table row on every render.

Definitely fine for the POC, but I think our goal for this PR, probably even a requirement? for v1, is that as the rows are dragged up/down, React doesn't re-render them at all, and instead they're just swapped in the DOM or what not.

I'm kinda surprised, because at first I thought "oh this rowArray is re-created on each render/something was going to be a hopefully easy fix for this, but your story is only using this for initializing of [rows, setRows], and then afaict everything after that is keeping the same "stable row identities" and just swapping them around in the list...

Which I was really hoping would be all we'd need to get for the "don't re-render each row" stuff to work, but it sounds like it's not...

Is that something you've observed/are thinking about/have any ideas yet as to why data-render isn't stable as we drag?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, that is a good call-out. I'm not sure why that would be happening here but I will look into it before I get a final PR ready.


const [rows, setRows] = useState<GridDataRow<Row>[]>([simpleHeader, ...rowArray]);

// also works with as="table" and as="virtual"
return (
<GridTable
columns={[nameColumn, actionColumn]}
onRowDrop={(draggedRow, droppedRow, indexOffset) => {
const tempRows = [...rows];
// remove dragged row
// console.log("onRowDrop");
// console.log("rows", rows);
const draggedRowIndex = tempRows.findIndex((r) => r.id === draggedRow.id);
if (draggedRowIndex === -1) {
console.error("draggedRowIndex is -1");
console.log("draggedRow", draggedRow);
console.log("draggedRowIndex", draggedRowIndex);
return;
}
const reorderRow = tempRows.splice(draggedRowIndex, 1)[0];
// console.log("reorder row removed");

const droppedRowIndex = tempRows.findIndex((r) => r.id === droppedRow.id);

// console.log("ondrop: " + draggedRow.id + " -> " + droppedRow.id);

if (draggedRowIndex === -1 || droppedRowIndex === -1) {
console.error("draggedRowIndex or droppedRowIndex is -1");
console.log("draggedRow", draggedRow);
console.log("draggedRowIndex", draggedRowIndex);
console.log("droppedRow", droppedRow);
console.log("droppedRowIndex", droppedRowIndex);
} else {
// console.log("insert row at index " + droppedRowIndex);
// console.log("indexOffset", indexOffset);
// insert it at the dropped row index
setRows([...insertAtIndex(tempRows, reorderRow, droppedRowIndex + indexOffset)]);
}
}}
rows={[...rows]}
/>
);
}

export const DraggableWithInputColumns = newStory(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

curious @bsholmes - For these last 2 columns, the dragged item does not remain in the position it is dropped. Is that expected? Am I doing something wrong like dropping things where they are not allowed to be?

Copy link
Contributor Author

@bsholmes bsholmes Jan 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I neglected to implement the drop event handler in those stories, should be there now

() => {
const nameCol = column<Row2>({ header: "Name", data: ({ name }) => name });
const priceCol = numericColumn<Row2>({
header: "Price",
data: ({ priceInCents }) => <NumberField label="Price" value={priceInCents} onChange={noop} type="cents" />,
});
const actionCol = actionColumn<Row2>({ header: "Action", data: () => <IconButton icon="check" onClick={noop} /> });
return (
<GridTable<Row2>
columns={[nameCol, priceCol, actionCol]}
rows={[
simpleHeader,
{
kind: "data",
id: "1",
data: { name: "Foo", role: "Manager", date: "11/29/85", priceInCents: 113_00 },
draggable: true,
},
{
kind: "data",
id: "2",
data: { name: "Bar", role: "VP", date: "01/29/86", priceInCents: 1_524_99 },
draggable: true,
},
{
kind: "data",
id: "3",
data: { name: "Biz", role: "Engineer", date: "11/08/18", priceInCents: 80_65 },
draggable: true,
},
{
kind: "data",
id: "4",
data: { name: "Baz", role: "Contractor", date: "04/21/21", priceInCents: 12_365_00 },
draggable: true,
},
]}
onRowDrop={() => {}}
/>
);
},
{ decorators: [withRouter()] },
);

const draggableRows = makeNestedRows(1, true);
const draggableRowsWithHeader: GridDataRow<NestedRow>[] = [simpleHeader, ...draggableRows];
export function DraggableNestedRows() {
const nameColumn: GridColumn<NestedRow> = {
header: () => "Name",
parent: (row) => ({
content: <div>{row.name}</div>,
value: row.name,
}),
child: (row) => ({
content: <div css={Css.ml2.$}>{row.name}</div>,
value: row.name,
}),
grandChild: (row) => ({
content: <div css={Css.ml4.$}>{row.name}</div>,
value: row.name,
}),
add: () => "Add",
};
return (
<GridTable
columns={[collapseColumn<NestedRow>(), nameColumn]}
{...{ rows: draggableRowsWithHeader }}
sorting={{ on: "client", initial: ["c1", "ASC"] }}
onRowDrop={(draggedRow, droppedRow) => {}}
/>
);
}
5 changes: 5 additions & 0 deletions src/components/Table/GridTable.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ const rows: GridDataRow<Row>[] = [
{ kind: "data", id: "1", data: { name: "foo", value: 1 } },
{ kind: "data", id: "2", data: { name: "bar", value: 2 } },
];
const draggableRows: GridDataRow<Row>[] = [
simpleHeader,
{ kind: "data", id: "1", data: { name: "foo", value: 1 }, draggable: true },
{ kind: "data", id: "2", data: { name: "bar", value: 2 }, draggable: true },
];

// Make a `NestedRow` ADT for a table with a header + 3 levels of nesting
type TotalsRow = { kind: "totals"; id: string; data: undefined };
Expand Down
Loading
Loading