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

Conversation

bsholmes
Copy link
Contributor

@bsholmes bsholmes commented Dec 15, 2023

Add drag events to GridTable rows in order to enable drag & drop reordering

https://app.shortcut.com/homebound-team/story/43819/implement-drag-drop-reordering-for-the-configure-options-table

Copy link

@bsholmes bsholmes changed the title [SC-43819] draggable table rows feat: [SC-43819] draggable table rows Dec 15, 2023
/**
* 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.

spacer: " ",
clientSideSort: false,
};
const spacerRow: GridDataRow<OrderedRow<Data>> = {
Copy link
Contributor

Choose a reason for hiding this comment

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

Thought: I think this spacer row is a good idea; afaict it like follows the mouse around to show where the being-dragged row will end up going?

onRowDragEnd={{ data: onDragEnd, spacer: onDragEnd }}
onRowDrop={{ data: onDrop, spacer: onDrop }}
onRowDragEnter={{ data: onDragEnter, spacer: onDragEnter }}
onRowDragOver={{ data: onDragOver, spacer: onDragOver }}
Copy link
Contributor

Choose a reason for hiding this comment

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

Thought: I think this makes a ton of sense for prototyping/spiking; wondering if maybe before shipping the final API might be more short & sweet, like something like:

  • onRowMove((row, oldIndex, newIndex) => ...)

And have all of the low-level drag math, and ideally like management of that spacer row (being injected/moving around within the page's rows), all managed internally for the page... 🤔

We could also add a few props to our GridStyle, like dragSpacerRow: { ...css stuff... } to hopefully both have our out-of-the-box styles support it already, but also pages could tweak it if/as needed.

@@ -372,4 +379,6 @@ export type GridDataRow<R extends Kinded> = {
selectable?: false;
/** Whether this row should infer its selected state based on its children's selected state */
inferSelectedState?: false;
/** Whether this row is draggable, usually to allow drag & drop reordering of rows */
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.

nit: Probably worth making this a boolean otherwise TS will sometimes require us to do draggable: true as const. If we allow it to be a boolean value, then we don't need the as const suffix.

@@ -113,7 +120,7 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
let expandColumnHidden = false;

return (
<RowTag css={rowCss} {...others} data-gridrow {...getCount(row.id)}>
<RowTag css={rowCss} {...others} data-gridrow {...getCount(row.id)} draggable={row.draggable}>
Copy link
Contributor

Choose a reason for hiding this comment

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

thought/suggestion: I think it'd be great to make a specific "drag handle" and test out what that looks like as well.
If we have a row with many other interactive elements inside of it we could have some potentially conflicting interactions (i dunno if that'd be true, but I'd rather avoid it).

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 I'm planning on adding that as well. Specifically on mobile I think it is necessary since dragging is the default behavior for scrolling through the table.

Copy link
Contributor

@bdow bdow left a comment

Choose a reason for hiding this comment

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

Nice spike! I agree with Stephen regarding simplifying the API so not every caller needs to implement all the onDragFoo properties.

Copy link
Contributor

@bdow bdow left a comment

Choose a reason for hiding this comment

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

Would also be great to test this out on our as="table" and as="virtual" variants.
When we use as="virtual" we are leveraging the React-Virtuoso library, which has an example of how to integrate with React Beautiful DnD: https://virtuoso.dev/react-beautiful-dnd/

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.

}

evt.dataTransfer.effectAllowed = "move";
evt.dataTransfer.setData("text/plain", JSON.stringify({ row }));
Copy link
Contributor

Choose a reason for hiding this comment

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

Thought: The row might be a little big, although maybe it doesn't matter? I'd be tempted to just do like setdata("text/plain", row.id) because the row ids should be unique.

Copy link
Contributor

@stephenh stephenh left a comment

Choose a reason for hiding this comment

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

The code looks really neat/succinct; I think only actually issue is useCallback-ing the functions to get GridTable perf okay (keep all the rows memoized), by either just-use-useCallback or by moving the function declarations someplace already-stable like RowStates (my preference but likely because I like mobx :-).

@@ -81,6 +94,8 @@ function RowImpl<R extends Kinded, S>(props: RowProps<R>): ReactElement {
const levelIndent = style.levels && style.levels[level]?.rowIndent;

const rowCss = {
// ...Css.add("transition", "padding-top 0.5s ease-in-out").$,
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: can remove

@@ -59,6 +67,7 @@ export class RowState<R extends Kinded> {
_row: false,
_data: observable.ref,
isCalculatingDirectMatch: false,
isDraggedOver: observable.ref, // allows the table to re-render only this row when the dragged over state changes
Copy link
Contributor

Choose a reason for hiding this comment

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

Question: Given that isDraggedOver is a primitive, I think this is unnecessary? Usually the observable.ref short-circuits mobx from doing a deep compare, which is how primitives basically work by default.

@@ -274,6 +290,111 @@ export function GridTable<R extends Kinded, X extends Only<GridTableXss, X> = an
const expandedColumnIds: string[] = useComputed(() => tableState.expandedColumnIds, [tableState]);
const columnSizes = useSetupColumnSizes(style, columns, resizeTarget ?? resizeRef, expandedColumnIds);

function isCursorBelowMidpoint(target: HTMLElement, clientY: number) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Minor: Maybe move to the utils file given afaict the function doesn't need to capture any state.

});
}

function onDragStart(row: GridDataRow<R>, evt: DragEventType) {
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggestion: I think useCallback-ing all of these functions will be necessary to keep the all rows from re-rendering on each GridTable render. As-is the Row props have different values/new functions on each GridTable render.

...it might be "too cute" but fwiw that RowStates / TableState classes are effectively already cached/1:1 with each GridTable, so if you did something like TableState.onDragStart (all of these become methods on TableState or RowStates), and then inside of of Row you could do onDragStart => tableState.onDragStart(row, evt).

I guess you'd need the setDraggedRow setter, but you could do something like a TableState.draggedRow field, that just gets set... (i.e. skip the useState + useRef pair, and just use a field on the mobx TableState to achieve basically the same thing...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I added a test to GridTable.test.js to look at row rerenders for draggable rows and I believe they already only rerender on data change, just like the non-draggable rows, with the exception that they need to rerender when I'm setting css for the spacer and when they get reordered.

ref={ref}
>
{row.draggable && (
<div
Copy link
Contributor

Choose a reason for hiding this comment

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

Thought: I kinda think we need this div to go inside one of the columns, otherwise we're throwing off the column alignment between the header row (2 divs/columns) & the table rows (3 divs/columns)...

Maybe fine to ship for now/for our usage?

We could potentially give users a <DragHandle /> component that we just tell them "put this into your 1st column" ... or a dragColumn helper like in our columns.tsx in beam, there is a dateColumn, actionColumn, etc... That would be a place to put this code that doesn't throw off the column alignment...

Oh, yeah, the selectColumn and collapseColumn in particular handle being dropped into any table/without knowing the table's kinds...

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good call, I added a drag handle column as you suggested. The only thing that makes this a little odd is that setting the draggable flag and providing a drop callback won't really be usable unless that column is also provided.

Copy link
Contributor

Choose a reason for hiding this comment

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

...won't really be usable unless that column is also provided.

True...if you wanted, you could do something like "if (any row has draggable || drop callback set) && (!columns has a drag column) throw error("please add a drag column").

That sort of polished UX/DX isn't really common/expected, but pretty neat/appreciated when we do have it. :-)

Copy link
Contributor

@chr1sjf0x chr1sjf0x left a comment

Choose a reason for hiding this comment

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

Looks pretty good to me overall. I'll defer to @stephenh on the final 👍 given his suggestions.

);
}

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

@bsholmes bsholmes merged commit 16b883b into main Jan 10, 2024
6 checks passed
@bsholmes bsholmes deleted the sc-43819-draggable-table-rows branch January 10, 2024 19:50
homebound-team-bot pushed a commit that referenced this pull request Jan 10, 2024
## [2.333.0](v2.332.1...v2.333.0) (2024-01-10)

### Features

* [SC-43819] draggable table rows ([#987](#987)) ([16b883b](16b883b))
@homebound-team-bot
Copy link

🎉 This PR is included in version 2.333.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants