drag and drop with horizontal movement for level#47824
Conversation
6766f41 to
3bb5fde
Compare
|
@ssilvert could you review? |
|
@edewit I'm getting compilation errors when I try to run vite. |
|
ahh yeah sorry... there was a merge error |
1e33a7a to
ecc3071
Compare
There was a problem hiding this comment.
Pull request overview
This PR replaces the Admin UI authentication flow reordering implementation with a new drag-and-drop approach (dnd-kit) that supports horizontal movement to change nesting level, enabling dropping an execution into a subflow.
Changes:
- Migrates flow table drag/drop from PatternFly’s
DragDropto@dnd-kit/corewith a dedicated grip handle and drag overlay. - Adds “horizontal indent” handling during drag to preview/perform nesting into subflows, backed by new
ExecutionList.resolveDropTarget()logic and unit tests. - Updates Playwright E2E coverage and adds new UI strings + styling for drag affordances and drop indicators.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| js/pnpm-lock.yaml | Locks newly added dnd-kit dependencies. |
| js/apps/admin-ui/package.json | Adds dnd-kit dependencies for the Admin UI. |
| js/apps/admin-ui/src/authentication/FlowDetails.tsx | Introduces dnd-kit context, drag sensors, live announcements, and overlay; wires drop preview + execution changes. |
| js/apps/admin-ui/src/authentication/execution-model.ts | Adds drop targeting/preview types and implements drop resolution for reorder vs level-change. |
| js/apps/admin-ui/src/authentication/components/FlowRow.tsx | Adds a per-row grip drag handle, drop preview state rendering, and related data attributes. |
| js/apps/admin-ui/src/authentication/components/flow-row.css | Adds visual styling for drag state, drop lines, nesting highlight, and overlay. |
| js/apps/admin-ui/src/authentication/components/FlowTitle.tsx | Removes the unused “step” type/icon usage to align with the updated row typing. |
| js/apps/admin-ui/src/authentication/tests/ExecutionList.test.ts | Adds unit tests for getChange and resolveDropTarget behavior across nested/collapsed cases. |
| js/apps/admin-ui/test/autentication/flows.spec.ts | Updates existing DnD test to use the grip handle + adds a new horizontal-indent nesting test. |
| js/apps/admin-ui/maven-resources/theme/keycloak.v2/admin/messages/messages_en.properties | Adds new live-region/message strings for drag instructions and “move into subflow” announcements. |
Files not reviewed (1)
- js/pnpm-lock.yaml: Generated file
| const targetParent = | ||
| vertical === "into" | ||
| ? hover | ||
| : clampedLevel > 0 | ||
| ? this.#getParentFromVisualOrder( | ||
| clampedLevel, | ||
| insertIndex, | ||
| visualOrder, | ||
| ) | ||
| : undefined; | ||
|
|
||
| const targetParentId = | ||
| clampedLevel > 0 && targetParent?.authenticationFlow | ||
| ? (targetParent.id ?? null) | ||
| : null; |
| {activeId ? ( | ||
| <DragOverlayContent | ||
| execution={ | ||
| executionList.order().find((ex) => ex.id === activeId)! | ||
| } | ||
| targetParentName={ | ||
| dropInfo.targetParentId | ||
| ? findExecutionById(dropInfo.targetParentId) | ||
| ?.displayName | ||
| : undefined | ||
| } | ||
| /> | ||
| ) : null} |
| <Table aria-label={t("flows")} isTreeTable> | ||
| <FlowHeader /> | ||
| <> |
| onDragMoveIntoSubflow={{item}} is moved into {{subflow}} | ||
| executionDragInstructions=Drag and drop executions to reorder them. Moving them horizontally will change on which level they are inserted. |
| .keycloak__authentication__drag-handle:hover { | ||
| color: var(--pf-v5-global--Color--100); | ||
| background-color: var(--pf-v5-global--BackgroundColor--200); | ||
| } | ||
|
|
||
| .keycloak__authentication__drag-handle:active { | ||
| cursor: grabbing; | ||
| } | ||
|
|
| "@dagrejs/dagre": "^1.1.8", | ||
| "@dnd-kit/core": "^6.1.0", | ||
| "@dnd-kit/sortable": "^8.0.0", | ||
| "@dnd-kit/utilities": "^3.2.2", | ||
| "@keycloak/keycloak-admin-client": "workspace:*", | ||
| "@keycloak/keycloak-ui-shared": "workspace:*", | ||
| "@patternfly/patternfly": "^5.4.2", |
| "@dnd-kit/core": "^6.1.0", | ||
| "@dnd-kit/sortable": "^8.0.0", | ||
| "@dnd-kit/utilities": "^3.2.2", |
| onDragMove=Dragging item {{item}} | ||
| onDragMoveIntoSubflow={{item}} is moved into {{subflow}} | ||
| executionDragInstructions=Drag and drop executions to reorder them. Moving them horizontally will change on which level they are inserted. |
| const currentDropInfo = dropInfo; | ||
| setDropInfo(emptyDropInfo()); | ||
|
|
| } from "@patternfly/react-icons"; | ||
| import { Table, Tbody } from "@patternfly/react-table"; | ||
| import { useState } from "react"; | ||
| import { useCallback, useMemo, useRef, useState } from "react"; |
| const { active } = event; | ||
| const { x: pointerX, y: pointerY } = pointer; | ||
| const rows = document.querySelectorAll("tr[data-execution-id]"); | ||
| const hoveredRow = findHoveredRow( |
| const collapseSnapshotRef = useRef<Map<string, boolean>>(new Map()); | ||
| const autoExpandedIdsRef = useRef<Set<string>>(new Set()); | ||
| const expandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); | ||
| const pendingExpandIdRef = useRef<string | null>(null); | ||
| const collapseRafRef = useRef<number | null>(null); |
| const { rect, isSubflow } = hoveredRow; | ||
| const rowHeight = rect.height; | ||
| const relativeY = pointerY - rect.top; | ||
| const edgeZone = rowHeight * ROW_EDGE_ZONE_RATIO; |
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
| ? new LevelChange( | ||
| hover.executionList?.length || 0, | ||
| this.#siblingIndexAtEndOfSubflow(draggedId, hover), | ||
| hover, | ||
| ) |
| const needsLevelChange = | ||
| vertical === "into" || | ||
| clampedLevel !== draggedLevel || | ||
| currentParent?.id !== targetParent?.id; |
| const needsLevelChange = | ||
| vertical === "into" || | ||
| clampedLevel !== draggedLevel || | ||
| currentParent?.id !== targetParent?.id; |
| }, | ||
| }); | ||
|
|
||
| await assertNotificationMessage(page, "Flow successfully updated"); |
| ? new LevelChange( | ||
| hover.executionList?.length || 0, | ||
| this.#siblingIndexAtEndOfSubflow(draggedId, hover), | ||
| hover, | ||
| ) |
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
|
|
||
| return { | ||
| kind: "reorder", | ||
| change: this.getChange(dragged, order), |
| const needsLevelChange = | ||
| vertical === "into" || | ||
| clampedLevel !== draggedLevel || | ||
| currentParent?.id !== targetParent?.id; |
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
|
|
||
| it("returns null for into on non-subflow", () => { | ||
| const list = new ExecutionList(list2Data); | ||
| expect(list.resolveDropTarget("1", "1", "into")).toBeNull(); |
ssilvert
left a comment
There was a problem hiding this comment.
I tried this out and I think it's getting very close to what we want.
The flows now expand when you hover, but they also need to contract when you hover so that you can change your mind and easily place something outside the flow.
It would also be helpful if the blue line indicating the target drop location looked different from the line showing a selection. Maybe a different color and appearance. For instance, here it's a little hard to tell which solid blue line is the drop indicator.
Lastly, it would be better if The text said exactly where the moved execution will land. In the case above, it would be great if it said, "Cookie will be moved into Copy of browser forms below Username Password Form"
| const dragged = this.#findInTree(draggedId); | ||
| const hover = visualOrder[hoverIndex]; | ||
| if (!dragged || draggedId === hoverId) { | ||
| return null; | ||
| } |
| (change instanceof LevelChange && | ||
| change.oldIndex === change.newIndex && | ||
| resolved.kind === "level-change" && | ||
| currentParent?.id === change.parent?.id); |
| if (executionFlow.executionList?.length) { | ||
| const parentForChildren = { ...executionFlow, id: result.id }; | ||
| for (const [i, child] of executionFlow.executionList.entries()) { | ||
| await executeChange( | ||
| child, | ||
| { | ||
| parent: parentForChildren, | ||
| newIndex: i, | ||
| oldIndex: i, | ||
| }, | ||
| { silent: true, snapshot: subtreeSnapshot.children[i] }, | ||
| ); | ||
| } |
There was a problem hiding this comment.
this is a known limitation of the drag and drop support in it's current state
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
There was a problem hiding this comment.
This is a larger issue and something that the PF implementation also lacked it's been tracked in this issue #50863
73d55bd to
6ccf92f
Compare
|
@ssilvert right that makes sense updated with your comments |
This adds the ability to drag and drop a excution into a subflow by holding <shift> when starting the drag. related: keycloak#44026 Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Using dnd-kit directly for more control, now you can drop executions into subflows and there is a visual indicator that when you do Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Instead of using horizontal movement to indicate the level we now use collapse and expand Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
Signed-off-by: Erik Jan de Wit <erikjan.dewit@gmail.com>
6ccf92f to
559a90d
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 14 changed files in this pull request and generated no new comments.
Files not reviewed (1)
- js/pnpm-lock.yaml: Generated file
Comments suppressed due to low confidence (4)
js/apps/admin-ui/src/authentication/FlowDetails.tsx:634
- Dropping a non-last child onto its current parent should append it, but this check treats every such drop as a no-op because
LevelChange.oldIndexis the post-recreation append index, not the child's current index. Comparedragged.indexwithchange.newIndex; otherwise only the last child should be considered unchanged.
(change instanceof LevelChange &&
change.oldIndex === change.newIndex &&
resolved.kind === "level-change" &&
currentParent?.id === change.parent?.id);
js/apps/admin-ui/src/authentication/FlowDetails.tsx:781
- For a recreated subflow,
addFlowToFlowreturns the new flow ID, not the execution ID (seeAuthenticationManagementResource.java:503,557-558). Consequently this requirement lookup never finds the subflow execution, and any subsequent priority calls also use the wrong ID; resolve the newly created execution by matching itsflowIdbefore restoring requirement or priority.
if (originalRequirement) {
await restoreExecutionRequirement(id, originalRequirement);
js/apps/admin-ui/src/authentication/FlowDetails.tsx:728
- The snapshot now captures
authenticationConfigfor every node, but the subflow branch never recreates it; form-flow subflow executions can carry a config (AuthenticationManagementResource.java:712-713). Moving such a subflow therefore permanently drops its configuration; recreate this config after resolving the new subflow execution ID.
const subtreeSnapshot =
snapshot ??
(await snapshotExecutionSubtree(ex as ExpandableExecution));
const config = subtreeSnapshot.config;
js/apps/admin-ui/src/authentication/FlowDetails.tsx:735
- This recreation branch still hard-codes
type: "basic-flow", so dragging aform-flowsubflow changes its flow type and allowed execution/requirement semantics. Snapshot the referenced flow's provider/type and pass that original type toaddFlowToFlowinstead.
if ("authenticationFlow" in ex && ex.authenticationFlow) {
This adds the ability to drag and drop a excution into a subflow
related: #44026
Signed-off-by: Erik Jan de Wit erikjan.dewit@gmail.com