Skip to content

ERA-11053: Support EFB schemas in ER > Collections > Drag-and-drop #1225

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

Merged
merged 8 commits into from
Feb 7, 2025

Conversation

luixlive
Copy link
Contributor

@luixlive luixlive commented Jan 30, 2025

What does this PR do?

Add dnd-kit to the project and implement a drag and drop interface to the collection lists in the new SchemaForm to reorder the items.

How does it look

image

Relevant link(s)

Where / how to start reviewing (optional)

The easier way is to go from top to bottom.

  • The .../SchemaForm/fields/Collection/index.js component is the entrypoint for the rendering of the collections. There you'll see that we pulled the item state to the parent so we handle a new items state variable that holds a temporal id, and the state of the modal and form preview of each item, making it more flexible to change those states from the parent. Also, from here we render the new SortableList.

  • Then, we have .../SchemaForm/fields/Collection/SortableList/index.js from where we render the <DndContext .../> and the <SortableContext .../> providers, needed to use the useSortable hook in the draggable items. This component configures and handles the sensors, accessibility (announcements, screen reader instructions, keyboard navigation), dragging events and everything that is related to the implementation of dnd-kit from the list perspective. It also renders the items of the collection through the new SortableItem.

  • .../SchemaForm/fields/Collection/SortableList/SortableItem/index.js is wrapper over our existing Item component that registers the item as a sortable through the useSortable hook and injects the properties (attributes, listeners, refs...) to the list items so they work as draggable elements.

In the rest of the files you will see other implementation details related to the drag and drop, like new hovering styles, a drag handle, etc.

@luixlive luixlive marked this pull request as draft January 30, 2025 22:15
@@ -157,21 +157,28 @@
"closed": "Open the {{collectionLabel}} list",
"open": "Close the {{collectionLabel}} list"
},
"item": {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The old item translationd didn't really change, they were just nested under sortableList to keep the same organization than the file structure.

The new translations added are the screen reader instructions and the announcements to stay a11y compliant. The new keys are: dragCancelAnnouncement, dragEndAnnouncement, draggableScreenReaderInstructions, dragOverAnnouncement and dragStartAnnouncement.

});

const hasError = !!errors;

return <ul
className={`${styles.formPreview} ${hasError ? styles.error : ''}`}
className={`${styles.formPreview} ${isDragOverlay ? styles.dragOverlay : ''} ${hasError ? styles.error : ''}`}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We add styles if the FormPreview is being rendered as part of a drag overlay. Specifically, a blue border:
image

@@ -87,6 +83,21 @@ const Item = ({
setIsFormModalOpen(false);
};

const onActionButtonKeyDown = (event) => {
Copy link
Contributor Author

@luixlive luixlive Jan 30, 2025

Choose a reason for hiding this comment

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

A few key changes to the Item component:

  • The action buttons now stop the propagation when the user clicks them while navigating the widget with the keyboard. Enter and Space keys are also used by the sortable to initiate the dragging mode, so if the focus is in the buttons we stop the propagation and just trigger the click.
  • A useEffect that listens to the isDragging prop to set the cursor as grabbing in the entire document until the user drops the item.
  • The new drag handle with its hover state.
  • The action button listeners get disables if the Item is just a drag overlay.
  • Other styles for the drag overlay and if the item is being dragged.

image

@@ -53,3 +53,8 @@ export const getHumanizedValue = (field, value, defaultHumanizedValue, language,
return value;
};
};

export const getItemTitle = (formData, identifier, defaultTitle, identifierField, language, t) =>
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This method isn't new, but it was moved to the utils file so it can be reused by the new SortableList when rendering the a11y announcement texts.


const BOOTSTRAP_COLLAPSE_TRANSITION_TIME = '300ms';

const SortableItem = ({ id, isFormModalOpen = false, ...otherProps }) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is just a wrapper for our Item component that registers it to the @dnd-kit framework as a new sortable node (through useSortable) and injects the properties it needs. Of course we could have just placed this code directly in the Item, but we need the Item presentational component for our drag overlay, not as a sortable. This ref forwarding pattern is suggested by the @dnd-kit docs in the Drag Overlay section: https://docs.dndkit.com/api-documentation/draggable/drag-overlay#ref-forwarding

const BOOTSTRAP_MODAL_ZINDEX = 1055;
const ITEM_HEADER_HEIGHT = 40;

const customKeyboardCoordinateGetter = (event, args) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

A custom coordinate getter algorithm. It's very simple and straightforward. It would have been better to follow the approach of the sortableKeyboardCoordinates that comes included in the sortable preset of dnd-kit: https://docs.dndkit.com/presets/sortable#sensors . However, given that we can open our collection item previews, the sizes of the items is unknown and the included algorithm doesn't work properly. With this custom implementation we move the item with every arrow up or down the user presses the exact height of a closed item. If all the items are closed it will work perfectly, if not, you will have to press several times to do an actual swap, but still works.

setIsItemFormPreviewOpen,
renderField,
}) => {
const sensors = useSensors(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We configure three sensors for maximal accessibility: mouse, touch and keyboard. We configure them with proper activator constraints so the buttons inside the items (delete, edit, open / close) are still clickable and draggable.

...(overId === null ? [] : [items.findIndex((item) => item.id === overId) + 1]),
];
};
const dndAnnouncements = {
Copy link
Contributor Author

@luixlive luixlive Jan 30, 2025

Choose a reason for hiding this comment

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

Announcements that go in a hidden live region for people using screen readers to know the events that get fired while they interact with the drag and drop interface.
image

Example of the live region reporting that Joshua was dropped at position 2 of 3..

};

return <ul>
<DndContext
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The implementation is very readable, and I followed the patterns described in the docs. Nothing weird here:

<DndContext {...configurationProps}>
  <SortableContext
    items={temporalIdsForOurCollectionItems}
    strategy={verticalListSortingStrategy}
  >
    // SortableItem calls useSortable with the id and injects the properties
    {items.map((item) => <SortableItem />)}
  </SortableContext>
</DndContext>

// Items is an internal state variable to assign temporal ids to each collection item (used as the key prop, sortable
// id and numeric identifier) and to track their state. It's stored as an array and the index of each item in the
// value prop will always be matched in here.
const [items, setItems] = useState(value.map((_, index) => ({
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new state variable. We lifted the items isFormModalOpen and isFormPreviewOpen states up to the parent (collection) so we can easily control them from here and close / open the modal and the preview with other actions (like opening the modal immediately after the user creates a new item). It also holds the temporal id and since the structure is an array, it keeps the same ordering than the value array that contains the form data of each item. So the most important thing to track an item is to know its index which will be the same in value, in this items array and visually in the UI.

};

const onItemMove = (originalItemIndex, newItemIndex) => {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The new callback to trigger drag and drop reordering of the collection items. We get the original index and the new index and update the errors, the parent form data and the new items array using the dnd-kit helper arrayMove.

@luixlive luixlive changed the title ERA.11053: Support EFB schemas in ER > Collections > Drag-and-drop ERA-11053: Support EFB schemas in ER > Collections > Drag-and-drop Jan 30, 2025
@tiffanynwong
Copy link
Collaborator

Looking so nice 😎 Really like all the thought you put into the accessibility. The drag and drop looks really good.

In slack we talked about making the whole thing clickable. If it's possible/ easy can we also add a subtle shadow when hovering over an item. Just like we did in the form builder?
Jan-30-2025 13-22-29

@luixlive luixlive marked this pull request as ready for review February 4, 2025 15:50
@tiffanynwong
Copy link
Collaborator

The drag and drop looks good 😎 ! The only thing that may be missing is if we're going to address the cancel flow from the comments in the jira ticket. If not, approved!

@luixlive
Copy link
Contributor Author

luixlive commented Feb 6, 2025

@tiffanynwong

The drag and drop looks good 😎 ! The only thing that may be missing is if we're going to address the cancel flow from the comments in the jira ticket. If not, approved!

@JoshuaVulcan created a separate ticket to work on those changes 😄

Copy link

@fonzy911 fonzy911 left a comment

Choose a reason for hiding this comment

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

Hi @luixlive, this looks great. Approved!
I can definitely circle back to this is changes are made due to feedback but, this looks great!

Copy link
Contributor

@gaboDev gaboDev left a comment

Choose a reason for hiding this comment

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

LGTM 🥳

Copy link
Collaborator

@JoshuaVulcan JoshuaVulcan left a comment

Choose a reason for hiding this comment

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

As always, code looks good and the test coverage is A+. Look and feel of the app is great. I added a couple of minor questions/comments but it's nothing blocking at all. Well done

}, [isFormModalOpen]);

return <li
className={`${styles.item} ${isFormPreviewOpen ? styles.open : ''} ${isDragging ? styles.isDragging : ''} ${isDragOverlay ? styles.dragOverlay : ''} ${hasError ? styles.error : ''}`}
Copy link
Collaborator

Choose a reason for hiding this comment

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

At this point, the class string is so long that it may be more legible/reasonable to compute it above the return statement.

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'll do this and then merge 😄

</li>;
};

export default forwardRef(Item);
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why are there some redundant anonymous event handler functions throughout? Shouldn't those be named and consolidated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well I don't think we have a hard rule on this topic. My mind flow is usually as follows:

  • If a method isn't complex, don't memoize it. Memoization costs are usually more expensive than doing trivial operations like math, concatenation, calling a parent callback, sometimes even iterating a small array...
  • If a method isn't memoized and it's a one-liner, keep it anonymous in the prop definition.
  • If the method is not a one liner or needs to be memoized, define it in the component body with a nice self explanatory name.
    I'm always glad to set our team agreements on code style. Let me know what you think.

@luixlive luixlive merged commit bd59c17 into develop Feb 7, 2025
2 checks passed
@luixlive luixlive deleted the ERA-11053 branch February 7, 2025 22:42
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