-
Notifications
You must be signed in to change notification settings - Fork 2
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
Conversation
This pull request has been linked to Shortcut Story #43819: Implement Drag & Drop reordering for the configure options table. |
/** | ||
* Shows how drag & drop reordering can be implemented with GridTable drag events | ||
*/ | ||
export function DraggableRows() { |
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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!
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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>> = { |
There was a problem hiding this comment.
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 }} |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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}> |
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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.
There was a problem hiding this 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.
There was a problem hiding this 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, | ||
})); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 })); |
There was a problem hiding this comment.
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.
There was a problem hiding this 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").$, |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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.
src/components/Table/GridTable.tsx
Outdated
@@ -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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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. :-)
There was a problem hiding this 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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
## [2.333.0](v2.332.1...v2.333.0) (2024-01-10) ### Features * [SC-43819] draggable table rows ([#987](#987)) ([16b883b](16b883b))
🎉 This PR is included in version 2.333.0 🎉 The release is available on: Your semantic-release bot 📦🚀 |
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