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 |
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>
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", |
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>
| "@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); |
|
|
||
| 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(); |
| restoreDragState(); | ||
| setLiveText(t("onDragFinish", { list: dragged.displayName })); | ||
| void executeChange(dragged, change); |
| dropMode = vertical === "before" ? "reorder-before" : "reorder-after"; | ||
| insertIndex = vertical === "before" ? hoverIndex : hoverIndex + 1; | ||
| clampedLevel = hoverLevel; |
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
| await dragExecutionToRow(page, sourceRow, subflowRow, { yRatio: 0.5 }); | ||
|
|
||
| await assertNotificationMessage(page, "Flow successfully updated"); |
| restoreDragState(); | ||
| setLiveText(t("onDragFinish", { list: dragged.displayName })); | ||
| void executeChange(dragged, change); |
| restoreDragState(); | ||
| setLiveText(t("onDragFinish", { list: dragged.displayName })); | ||
| void executeChange(dragged, change); |
| if (originalRequirement) { | ||
| await adminClient.authenticationManagement.updateExecution( | ||
| { flow: flow?.alias! }, | ||
| { id, requirement: originalRequirement }, | ||
| ); | ||
| } |
| if (executionFlow.executionList?.length) { | ||
| const parentForChildren = { ...executionFlow, id: result.id }; | ||
| for (const [i, child] of executionFlow.executionList.entries()) { | ||
| await executeChange( | ||
| child, |
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), |
| const isNoOp = | ||
| change instanceof IndexChange && | ||
| change.oldIndex === change.newIndex && | ||
| resolved.kind === "reorder"; |
8cf656a to
d97ca75
Compare
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
| (change instanceof LevelChange && | ||
| change.oldIndex === change.newIndex && | ||
| resolved.kind === "level-change" && | ||
| currentParent?.id === change.parent?.id); |
| if (ex.authenticationFlow) { | ||
| const executionFlow = ex as ExpandableExecution; | ||
| const result = | ||
| await adminClient.authenticationManagement.addFlowToFlow({ |
| if (executionFlow.executionList?.length) { | ||
| const parentForChildren = { ...executionFlow, id: result.id }; |
| const row = page | ||
| .locator("tr[data-execution-id]") | ||
| .filter({ | ||
| has: | ||
| typeof executionName === "string" | ||
| ? page.getByTestId(executionName) | ||
| : page.getByRole("row", { name: executionName }), | ||
| }) | ||
| .first(); |
d97ca75 to
08711df
Compare
| type ExecutionMoveSnapshot = { | ||
| config: AuthenticatorConfigRepresentation; | ||
| children: ExecutionMoveSnapshot[]; | ||
| }; |
| (change instanceof LevelChange && | ||
| change.oldIndex === change.newIndex && | ||
| resolved.kind === "level-change" && | ||
| currentParent?.id === change.parent?.id); |
| } catch (error) { | ||
| if (silent) { | ||
| throw error; | ||
| } | ||
| addError("updateFlowError", error); | ||
| } |
| const sensors = useSensors( | ||
| useSensor(PointerSensor, { | ||
| activationConstraint: { | ||
| distance: 5, | ||
| }, | ||
| }), | ||
| ); |
| const row = page | ||
| .locator("tr[data-execution-id]") | ||
| .filter({ | ||
| has: | ||
| typeof executionName === "string" | ||
| ? page.getByTestId(executionName) | ||
| : page.getByRole("row", { name: executionName }), | ||
| }) | ||
| .first(); |
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 |
| // skipping already deleted execution | ||
| } | ||
| if ("authenticationFlow" in ex) { | ||
| if ("authenticationFlow" in ex && ex.authenticationFlow) { |
| if (executionFlow.executionList?.length) { | ||
| const parentForChildren = { ...executionFlow, id: result.id }; |
| const showDropLineAfter = | ||
| isDropTarget && | ||
| dropMode === "reorder-after" && | ||
| visualIndex !== undefined && | ||
| dropInfo?.insertIndex === visualIndex + 1; |
| await assertNotificationMessage(page, "Flow successfully updated"); | ||
| }); | ||
|
|
||
| test("drags execution into subflow by dropping on subflow row", async ({ |
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