Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion .github/workflows/studio-clientlib-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,19 +10,33 @@ on:
- 'scripts/integration-program/build_studio_clientlibs.sh'
- 'scripts/integration-program/consolidated-build/studio-bpmn-generator-layout.patch'
- 'scripts/integration-program/consolidated-build/studio-clientlib-lockfiles/**'
- 'test/studio/browser/DesignerHover/**'
- '.github/workflows/studio-clientlib-build.yml'
workflow_dispatch:

permissions:
contents: read

concurrency:
group: studio-clientlib-build-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
studio-clientlibs:
runs-on: ubuntu-latest
runs-on: ubuntu-24.04
timeout-minutes: 30
env:
EXPECTED_HEAD: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
repository: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
persist-credentials: false
- name: Verify exact candidate checkout
run: |
test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD"
git diff --exit-code HEAD --
- uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '10.0.300'
Expand All @@ -34,6 +48,9 @@ jobs:
run: |
if test -f src/studio/modules/Elsa.Studio.Workflows.Designer/ClientLib/package.json; then
echo 'mapped=true' >> "$GITHUB_OUTPUT"
elif test -d src/studio; then
echo 'Imported Studio is missing the required Designer ClientLib manifest.' >&2
exit 1
else
echo 'mapped=false' >> "$GITHUB_OUTPUT"
fi
Expand All @@ -49,6 +66,31 @@ jobs:
if: steps.source.outputs.mapped == 'false'
run: |
scripts/integration-program/build_studio_clientlibs.sh pinned-studio
- name: Verify browser proof source-binding guards
if: steps.source.outputs.mapped == 'true'
working-directory: test/studio/browser/DesignerHover
run: |
npm ci
npm test
- name: Build consolidated Studio ClientLibs after import
if: steps.source.outputs.mapped == 'true'
run: scripts/integration-program/build_studio_clientlibs.sh .
- name: Verify real X6 edge-hover behavior
if: steps.source.outputs.mapped == 'true'
working-directory: test/studio/browser/DesignerHover
run: |
npx --no-install playwright install --with-deps chromium
node run.mjs --expected-head "$EXPECTED_HEAD" --output "$RUNNER_TEMP/designer-hover-browser.json"
- name: Verify source remained unchanged
if: always()
run: |
test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD"
git diff --exit-code HEAD --
- name: Retain sanitized hover evidence
if: always() && steps.source.outputs.mapped == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: designer-hover-browser-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/designer-hover-browser.json
if-no-files-found: error
retention-days: 14
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
/**
* The remove button follows the pointer.
*
* The cases replay event sequences X6 raises when the pointer moves quickly: an edge is entered again
* before it was left, or the pointer reaches another edge, a node, the blank canvas or the outside of
* the graph without an `edge:mouseleave`. Each one used to leave a remove button behind.
*/
import {Edge} from '@antv/x6';
import {beforeEach, describe, expect, it} from 'vitest';
import {EdgeHoverEvents, registerEdgeHoverTools} from '../edge-hover-tools';

type Handler = (args: any) => void;

class FakeGraph {
private readonly handlers = new Map<string, Handler[]>();

readonly on = ((name: string, handler: Handler) => {
this.handlers.set(name, [...(this.handlers.get(name) ?? []), handler]);
return this;
}) as unknown as EdgeHoverEvents['on'];

emit(name: string, args: object = {}) {
for (const handler of this.handlers.get(name) ?? [])
handler(args);
}

listens(name: string) {
return this.handlers.has(name);
}
}

const newEdge = () => new Edge({source: {x: 0, y: 0}, target: {x: 100, y: 0}});
const toolNames = (edge: Edge) => (edge.getTools()?.items ?? []).map(item => typeof item === 'string' ? item : item.name);
const removeButtons = (edge: Edge) => toolNames(edge).filter(name => name === 'button-remove').length;

describe('registerEdgeHoverTools', () => {
let graph: FakeGraph;
let first: Edge;
let second: Edge;

beforeEach(() => {
graph = new FakeGraph();
first = newEdge();
second = newEdge();
registerEdgeHoverTools(graph, true);
});

it('shows the vertex handles and one remove button on the hovered edge', () => {
graph.emit('edge:mouseenter', {edge: first});

expect(toolNames(first)).toEqual(['vertices', 'button-remove']);
});

it('adds each tool once when an edge is entered again before it was left', () => {
graph.emit('edge:mouseenter', {edge: first});
graph.emit('edge:mouseenter', {edge: first});
graph.emit('edge:mouseenter', {edge: first});

expect(toolNames(first)).toEqual(['vertices', 'button-remove']);
});

it('removes the button when the pointer leaves the edge', () => {
graph.emit('edge:mouseenter', {edge: first});
graph.emit('edge:mouseleave', {edge: first});

expect(removeButtons(first)).toBe(0);
});

it('moves the button when the pointer reaches another edge without leaving the first', () => {
graph.emit('edge:mouseenter', {edge: first});
graph.emit('edge:mouseenter', {edge: second});

expect(removeButtons(first)).toBe(0);
expect(removeButtons(second)).toBe(1);
});

it('ignores a stale leave event from the previous edge', () => {
graph.emit('edge:mouseenter', {edge: first});
graph.emit('edge:mouseenter', {edge: second});
graph.emit('edge:mouseleave', {edge: first});

expect(removeButtons(first)).toBe(0);
expect(removeButtons(second)).toBe(1);
});

it.each(['node:mouseenter', 'blank:mouseover', 'graph:mouseleave'])(
'removes the button on %s when edge:mouseleave never fired',
event => {
graph.emit('edge:mouseenter', {edge: first});
graph.emit(event);

expect(removeButtons(first)).toBe(0);
});

it('forgets an edge that was removed while hovered', () => {
graph.emit('edge:mouseenter', {edge: first});
graph.emit('edge:removed', {edge: first});
graph.emit('edge:mouseenter', {edge: second});

expect(removeButtons(second)).toBe(1);
});

it('registers nothing when edges are not interactive', () => {
const nonInteractive = new FakeGraph();
registerEdgeHoverTools(nonInteractive, false);

const events = [
'edge:mouseenter',
'edge:mouseleave',
'node:mouseenter',
'blank:mouseover',
'graph:mouseleave',
'edge:removed',
];

for (const event of events)
expect(nonInteractive.listens(event)).toBe(false);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {getActivityMeasurementScopeClass} from "./calculate-activity-size";
import {arrangeSequenceGraph, moveSelectedSequenceNode, normalizeSequenceOrientation, withSuppressedGraphUpdated} from "./sequence-mode";
import {applyDesignerThemeVariables, X6DesignerTheme} from "./apply-graph-theme";
import {createDesignerGridOptions, resolveDesignerGridOptions} from "./grid-options";
import {registerEdgeHoverTools} from "./edge-hover-tools";
import {ActivityShape, FlowchartEdgeShape, getDesignerModePolicy, resolveDesignerMode} from './designer-mode';
import {applyStateMachineEdgeAccessibility, applyStateMachineNodeAccessibility} from '../internal/state-machine-accessibility';

Expand Down Expand Up @@ -334,31 +335,7 @@ export async function createGraph(containerId: string, componentRef: DotNetCompo
return false;
});

graph.on("edge:mouseenter", ({cell}) => {
if (!modePolicy.allowsInteractiveEdges) {
return false;
}

cell.addTools([
{name: "vertices"},
{
name: "button-remove",
args: {distance: 20},
},
]);
return false;
});

graph.on("edge:mouseleave", ({cell}) => {
if (!modePolicy.allowsInteractiveEdges) {
return false;
}

if (cell.hasTool("button-remove")) {
cell.removeTool("button-remove");
}
return false;
});
registerEdgeHoverTools(graph, !readOnly && modePolicy.allowsInteractiveEdges);

graph.on('node:click', async args => {
const {e, node} = args;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import {Edge, Graph} from '@antv/x6';

/** The subset of the graph the hover tools listen to. */
export type EdgeHoverEvents = Pick<Graph, 'on'>;

const vertices = 'vertices';
const removeButton = 'button-remove';

/**
* Shows the vertex handles and the remove button on the edge under the pointer.
*
* X6 appends a tool on every `addTools` call, and it does not raise `edge:mouseleave` when the pointer
* leaves an edge across one of its tools or straight onto another cell. So each tool is added only if
* the edge lacks it, and the remove button of the last hovered edge is cleared as soon as the pointer
* is seen over another edge, a node, the blank canvas, or outside the graph.
*/
export function registerEdgeHoverTools(graph: EdgeHoverEvents, enabled: boolean) {
if (!enabled)
return;

let hovered: Edge | null = null;

const hideRemoveButton = () => {
if (hovered?.hasTool(removeButton))
hovered.removeTool(removeButton);
hovered = null;
};

graph.on('edge:mouseenter', ({edge}) => {
if (hovered !== edge)
hideRemoveButton();

if (!edge.hasTool(vertices))
edge.addTools({name: vertices});
if (!edge.hasTool(removeButton))
edge.addTools({name: removeButton, args: {distance: 20}});

hovered = edge;
});

graph.on('edge:mouseleave', ({edge}) => {
// A late leave from an older edge must not clear the current edge's button.
if (hovered === edge)
hideRemoveButton();
});
graph.on('node:mouseenter', hideRemoveButton);
// `blank:mousemove` fires only while a button is held; hovering empty canvas raises `blank:mouseover`.
graph.on('blank:mouseover', hideRemoveButton);
graph.on('graph:mouseleave', hideRemoveButton);
graph.on('edge:removed', ({edge}) => {
if (hovered === edge)
hovered = null;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import { defineConfig } from 'vitest/config';
//
// Covered: src/bpmn, and the pure half of the X6 BPMN adapter under src/designer/bpmn -- its cell
// mapping, badge policy and graph options are all plain functions precisely so that they can be
// tested here rather than only in a browser. The rest of the designer and react-designer bundles
// have no test suite; they are covered by the .NET side.
// tested here rather than only in a browser. The flowchart designer's edge hover tools are covered
// the same way, through the graph events they listen to. The rest of the designer and react-designer
// bundles have no test suite; they are covered by the .NET side.
export default defineConfig({
test: {
environment: 'jsdom',
Expand Down
2 changes: 2 additions & 0 deletions test/studio/browser/DesignerHover/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
receipt.json
Loading
Loading