Skip to content

Commit b3845b5

Browse files
committed
feat: highlight triggered or errored block (frontend)
1 parent ae95de5 commit b3845b5

File tree

5 files changed

+239
-13
lines changed

5 files changed

+239
-13
lines changed

frontend/src/components/visual-editor/hooks/useVisualEditor.tsx

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,18 +8,20 @@
88

99
import { debounce } from "@mui/material";
1010
import createEngine, { DiagramModel } from "@projectstorm/react-diagrams";
11+
import { useRouter } from "next/router";
1112
import * as React from "react";
1213
import { createContext, useContext } from "react";
1314

1415
import { useCreate } from "@/hooks/crud/useCreate";
15-
import { EntityType } from "@/services/types";
16+
import { EntityType, RouterType } from "@/services/types";
1617
import { IBlock } from "@/types/block.types";
1718
import {
1819
BlockPorts,
1920
IVisualEditor,
2021
IVisualEditorContext,
2122
VisualEditorContextProps,
2223
} from "@/types/visual-editor.types";
24+
import { useSubscribe } from "@/websocket/socket-hooks";
2325

2426
import { ZOOM_LEVEL } from "../constants";
2527
import { AdvancedLinkFactory } from "../v2/AdvancedLink/AdvancedLinkFactory";
@@ -42,6 +44,8 @@ const addNode = (block: IBlock) => {
4244
patterns: (block?.patterns || [""]) as any,
4345
message: (block?.message || [""]) as any,
4446
starts_conversation: !!block?.starts_conversation,
47+
_hasErrored: false,
48+
_isHighlighted: false,
4549
});
4650

4751
node.setPosition(block.position.x, block.position.y);
@@ -252,6 +256,7 @@ const VisualEditorProvider: React.FC<VisualEditorContextProps> = ({
252256
children,
253257
}) => {
254258
const [selectedCategoryId, setSelectedCategoryId] = React.useState("");
259+
const router = useRouter();
255260
const { mutate: createBlock } = useCreate(EntityType.BLOCK);
256261
const createNode = (payload: any) => {
257262
payload.position = payload.position || getCentroid();
@@ -267,6 +272,130 @@ const VisualEditorProvider: React.FC<VisualEditorContextProps> = ({
267272
});
268273
};
269274

275+
async function removeHighlights() {
276+
return new Promise((resolve) => {
277+
if (!engine) {
278+
return;
279+
}
280+
281+
const nodes = engine.getModel().getNodes() as NodeModel[];
282+
283+
nodes.forEach((node) => {
284+
if (node.isHighlighted()) {
285+
node.setHighlighted(false);
286+
node.setSelected(false);
287+
}
288+
if (node.hasErrored()) {
289+
node.setHasErrored(false);
290+
}
291+
});
292+
293+
engine.repaintCanvas();
294+
resolve(true);
295+
});
296+
}
297+
function isBlockVisibleOnCanvas(block: NodeModel): boolean {
298+
const zoom = engine.getModel().getZoomLevel() / 100;
299+
const canvas = engine.getCanvas();
300+
const canvasRect = canvas?.getBoundingClientRect();
301+
302+
if (!canvasRect) {
303+
return false;
304+
}
305+
306+
const offsetX = engine.getModel().getOffsetX();
307+
const offsetY = engine.getModel().getOffsetY();
308+
const blockX = block.getX() * zoom + offsetX;
309+
const blockY = block.getY() * zoom + offsetY;
310+
const blockScreenX = blockX + (BLOCK_WIDTH * zoom) / 2;
311+
const blockScreenY = blockY + (BLOCK_HEIGHT * zoom) / 2;
312+
313+
return (
314+
blockScreenX > 0 &&
315+
blockScreenX < canvasRect.width &&
316+
blockScreenY > 0 &&
317+
blockScreenY < canvasRect.height
318+
);
319+
}
320+
321+
function centerBlockInView(block: NodeModel) {
322+
const zoom = engine.getModel().getZoomLevel() / 100;
323+
const canvasRect = engine.getCanvas()?.getBoundingClientRect();
324+
325+
if (!canvasRect) {
326+
return;
327+
}
328+
329+
const centerX = canvasRect.width / 2;
330+
const centerY = canvasRect.height / 2;
331+
const offsetX = centerX - (block.getX() + BLOCK_WIDTH / 2) * zoom;
332+
const offsetY = centerY - (block.getY() + BLOCK_HEIGHT / 2) * zoom;
333+
334+
engine.getModel().setOffset(offsetX, offsetY);
335+
}
336+
337+
async function redirectToTriggeredFlow(
338+
currentFlow: string,
339+
triggeredFlow: string,
340+
) {
341+
if (currentFlow !== triggeredFlow) {
342+
setSelectedCategoryId(triggeredFlow);
343+
}
344+
345+
const triggeredFlowUrl = `/${RouterType.VISUAL_EDITOR}/flows/${triggeredFlow}`;
346+
347+
if (window.location.href !== triggeredFlowUrl) {
348+
await router.push(triggeredFlow);
349+
}
350+
}
351+
352+
async function handleHighlightFlow(payload: any) {
353+
await removeHighlights();
354+
355+
await redirectToTriggeredFlow(selectedCategoryId, payload.flowId);
356+
357+
setTimeout(() => {
358+
const block = engine?.getModel().getNode(payload.blockId) as NodeModel;
359+
360+
if (!block) {
361+
return;
362+
}
363+
364+
if (!isBlockVisibleOnCanvas(block)) {
365+
centerBlockInView(block);
366+
}
367+
368+
block.setSelected(true);
369+
block.setHighlighted(true);
370+
371+
engine.repaintCanvas();
372+
}, 200);
373+
}
374+
375+
async function handleHighlightErroredBlock(payload: any) {
376+
await removeHighlights();
377+
378+
await redirectToTriggeredFlow(selectedCategoryId, payload.flowId);
379+
setTimeout(() => {
380+
const block = engine?.getModel().getNode(payload.blockId) as NodeModel;
381+
382+
if (!block) {
383+
return;
384+
}
385+
386+
if (!isBlockVisibleOnCanvas(block)) {
387+
centerBlockInView(block);
388+
}
389+
390+
block.setHasErrored(true);
391+
392+
engine.repaintCanvas();
393+
}, 220);
394+
}
395+
useSubscribe("highlight:flow", handleHighlightFlow);
396+
397+
useSubscribe("highlight:error", handleHighlightErroredBlock);
398+
270399
return (
271400
<VisualEditorContext.Provider
272401
value={{

frontend/src/components/visual-editor/v2/CustomDiagramNodes/NodeModel.tsx

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -8,8 +8,8 @@
88

99
import { BaseModelOptions } from "@projectstorm/react-canvas-core";
1010
import {
11-
DefaultNodeModel as StormNodeModel,
1211
DefaultPortModel,
12+
DefaultNodeModel as StormNodeModel,
1313
} from "@projectstorm/react-diagrams";
1414

1515
import { BlockPorts } from "@/types/visual-editor.types";
@@ -24,6 +24,8 @@ export interface NodeModelOptions extends BaseModelOptions {
2424
patterns?: string[];
2525
message?: string[];
2626
starts_conversation?: boolean;
27+
_isHighlighted: boolean;
28+
_hasErrored: boolean;
2729
}
2830

2931
export class NodeModel extends StormNodeModel {
@@ -36,8 +38,12 @@ export class NodeModel extends StormNodeModel {
3638
patterns: string[];
3739
message: string[];
3840
starts_conversation?: boolean;
41+
_isHighlighted: boolean = false;
42+
_hasErrored: boolean = false;
3943

40-
constructor(options: NodeModelOptions = {}) {
44+
constructor(
45+
options: NodeModelOptions = { _isHighlighted: false, _hasErrored: false },
46+
) {
4147
super({
4248
...options,
4349
type: "ts-custom-node",
@@ -74,6 +80,21 @@ export class NodeModel extends StormNodeModel {
7480
}),
7581
);
7682
}
83+
setHighlighted(isHighlighted: boolean) {
84+
this._isHighlighted = isHighlighted;
85+
}
86+
87+
isHighlighted(): boolean {
88+
return this._isHighlighted;
89+
}
90+
91+
setHasErrored(hasErrored: boolean) {
92+
this._hasErrored = hasErrored;
93+
this.fireEvent({ hasErrored }, "stateChanged");
94+
}
95+
hasErrored(): boolean {
96+
return this._hasErrored;
97+
}
7798

7899
serialize() {
79100
return {

frontend/src/components/visual-editor/v2/CustomDiagramNodes/NodeWidget.tsx

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import MenuRoundedIcon from "@mui/icons-material/MenuRounded";
1414
import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
1515
import ReplyIcon from "@mui/icons-material/Reply";
1616
import { Chip, styled } from "@mui/material";
17+
import { ListenerHandle } from "@projectstorm/react-canvas-core";
1718
import { DiagramEngine, PortWidget } from "@projectstorm/react-diagrams-core";
1819
import clsx from "clsx";
1920
import * as React from "react";
@@ -242,6 +243,7 @@ class NodeWidget extends React.Component<
242243
NodeWidgetProps & WithTranslation,
243244
NodeWidgetState
244245
> {
246+
listener: ListenerHandle | undefined;
245247
config: {
246248
type: TBlock;
247249
color: string;
@@ -253,15 +255,30 @@ class NodeWidget extends React.Component<
253255
this.config = getBlockConfig(this.props.node.message as any);
254256
}
255257

258+
componentDidMount() {
259+
this.listener = this.props.node.registerListener({
260+
stateChanged: () => this.forceUpdate(),
261+
});
262+
}
263+
264+
componentWillUnmount() {
265+
if (this.listener) {
266+
this.listener.deregister();
267+
}
268+
}
269+
256270
render() {
257271
const { t, i18n, tReady } = this.props;
272+
const selectedStyling = clsx(
273+
"custom-node",
274+
this.props.node.isSelected() ? "selected" : "",
275+
this.props.node.isHighlighted() ? "high-lighted" : "",
276+
this.props.node.hasErrored() ? "high-light-error" : "",
277+
);
258278

259279
return (
260280
<div
261-
className={clsx(
262-
"custom-node",
263-
this.props.node.isSelected() ? "selected" : "",
264-
)}
281+
className={selectedStyling}
265282
style={{
266283
border: `1px solid ${this.config.color}`,
267284
}}

frontend/src/styles/visual-editor.css

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
}
3535

3636
.start-point {
37-
color: #FFF;
37+
color: #fff;
3838
position: absolute;
3939
top: 50%;
4040
left: 50%;
@@ -183,7 +183,7 @@
183183
margin-top: 5px;
184184
border-radius: 100%;
185185
box-shadow: 0 0 8px #0003;
186-
transition: all .4s ease 0s;
186+
transition: all 0.4s ease 0s;
187187
}
188188
.circle-out-porters {
189189
position: absolute;
@@ -194,11 +194,11 @@
194194
.circle-porter-in {
195195
position: absolute;
196196
top: 50%;
197-
transform: translateY(-50%) scale(.75);
197+
transform: translateY(-50%) scale(0.75);
198198
left: -12px;
199199
}
200200
.circle-porter-out {
201-
transform: scale(.75);
201+
transform: scale(0.75);
202202
right: -12px;
203203
}
204204

@@ -211,3 +211,61 @@
211211
cursor: grab;
212212
transform: translateY(-50%) scale(1.1);
213213
}
214+
215+
.node:has(.high-lighted) {
216+
box-shadow: 0 0 14px 6px rgba(0, 153, 255, 0.6);
217+
z-index: 10;
218+
transform: scale(1.04);
219+
cursor: grab;
220+
transition: transform 0.3s ease, box-shadow 0.3s ease;
221+
border-radius: 10px;
222+
animation: highlightPulse 0.8s ease-out infinite;
223+
}
224+
225+
@keyframes highlightPulse {
226+
0% {
227+
box-shadow: 0 0 0 rgba(0, 153, 255, 0.2);
228+
transform: scale(1);
229+
}
230+
20% {
231+
box-shadow: 0 0 30px 12px rgba(0, 153, 255, 1);
232+
transform: scale(1.1);
233+
}
234+
60% {
235+
box-shadow: 0 0 20px 6px rgba(0, 153, 255, 0.7);
236+
transform: scale(1.06);
237+
}
238+
100% {
239+
box-shadow: 0 0 10px 4px rgba(0, 153, 255, 0.6);
240+
transform: scale(1.04);
241+
}
242+
}
243+
244+
.node:has(.high-light-error) {
245+
box-shadow: 0 0 14px 6px rgba(255, 0, 0, 0.6);
246+
z-index: 10;
247+
transform: scale(1.04);
248+
cursor: grab;
249+
transition: transform 0.3s ease, box-shadow 0.3s ease;
250+
border-radius: 10px;
251+
animation: highlightPulseError 0.8s ease-out infinite;
252+
}
253+
254+
@keyframes highlightPulseError {
255+
0% {
256+
box-shadow: 0 0 0 rgba(255, 0, 0, 0.2);
257+
transform: scale(1);
258+
}
259+
20% {
260+
box-shadow: 0 0 30px 12px rgba(255, 0, 0, 1);
261+
transform: scale(1.1);
262+
}
263+
60% {
264+
box-shadow: 0 0 20px 6px rgba(255, 0, 0, 0.7);
265+
transform: scale(1.06);
266+
}
267+
100% {
268+
box-shadow: 0 0 10px 4px rgba(255, 0, 0, 0.6);
269+
transform: scale(1.04);
270+
}
271+
}

frontend/src/websocket/socket-hooks.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright © 2024 Hexastack. All rights reserved.
2+
* Copyright © 2025 Hexastack. All rights reserved.
33
*
44
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
55
* 1. The name "Hexabot" is a trademark of Hexastack. You may not use this name in derivative works without express written permission.
@@ -41,6 +41,7 @@ export const SocketProvider = (props: PropsWithChildren) => {
4141
const [connected, setConnected] = useState(false);
4242
const { toast } = useToast();
4343
const { user } = useAuth();
44+
// todo: fix we aren't sending auth token
4445
const socket = useMemo(() => new SocketIoClient(apiUrl), [apiUrl]);
4546

4647
useEffect(() => {

0 commit comments

Comments
 (0)