feat: highlight triggered or errored block (frontend)

This commit is contained in:
abdou6666 2025-04-07 13:01:20 +01:00
parent ae95de5d1a
commit b3845b5f67
5 changed files with 239 additions and 13 deletions

View File

@ -8,11 +8,12 @@
import { debounce } from "@mui/material";
import createEngine, { DiagramModel } from "@projectstorm/react-diagrams";
import { useRouter } from "next/router";
import * as React from "react";
import { createContext, useContext } from "react";
import { useCreate } from "@/hooks/crud/useCreate";
import { EntityType } from "@/services/types";
import { EntityType, RouterType } from "@/services/types";
import { IBlock } from "@/types/block.types";
import {
BlockPorts,
@ -20,6 +21,7 @@ import {
IVisualEditorContext,
VisualEditorContextProps,
} from "@/types/visual-editor.types";
import { useSubscribe } from "@/websocket/socket-hooks";
import { ZOOM_LEVEL } from "../constants";
import { AdvancedLinkFactory } from "../v2/AdvancedLink/AdvancedLinkFactory";
@ -42,6 +44,8 @@ const addNode = (block: IBlock) => {
patterns: (block?.patterns || [""]) as any,
message: (block?.message || [""]) as any,
starts_conversation: !!block?.starts_conversation,
_hasErrored: false,
_isHighlighted: false,
});
node.setPosition(block.position.x, block.position.y);
@ -252,6 +256,7 @@ const VisualEditorProvider: React.FC<VisualEditorContextProps> = ({
children,
}) => {
const [selectedCategoryId, setSelectedCategoryId] = React.useState("");
const router = useRouter();
const { mutate: createBlock } = useCreate(EntityType.BLOCK);
const createNode = (payload: any) => {
payload.position = payload.position || getCentroid();
@ -267,6 +272,130 @@ const VisualEditorProvider: React.FC<VisualEditorContextProps> = ({
});
};
async function removeHighlights() {
return new Promise((resolve) => {
if (!engine) {
return;
}
const nodes = engine.getModel().getNodes() as NodeModel[];
nodes.forEach((node) => {
if (node.isHighlighted()) {
node.setHighlighted(false);
node.setSelected(false);
}
if (node.hasErrored()) {
node.setHasErrored(false);
}
});
engine.repaintCanvas();
resolve(true);
});
}
function isBlockVisibleOnCanvas(block: NodeModel): boolean {
const zoom = engine.getModel().getZoomLevel() / 100;
const canvas = engine.getCanvas();
const canvasRect = canvas?.getBoundingClientRect();
if (!canvasRect) {
return false;
}
const offsetX = engine.getModel().getOffsetX();
const offsetY = engine.getModel().getOffsetY();
const blockX = block.getX() * zoom + offsetX;
const blockY = block.getY() * zoom + offsetY;
const blockScreenX = blockX + (BLOCK_WIDTH * zoom) / 2;
const blockScreenY = blockY + (BLOCK_HEIGHT * zoom) / 2;
return (
blockScreenX > 0 &&
blockScreenX < canvasRect.width &&
blockScreenY > 0 &&
blockScreenY < canvasRect.height
);
}
function centerBlockInView(block: NodeModel) {
const zoom = engine.getModel().getZoomLevel() / 100;
const canvasRect = engine.getCanvas()?.getBoundingClientRect();
if (!canvasRect) {
return;
}
const centerX = canvasRect.width / 2;
const centerY = canvasRect.height / 2;
const offsetX = centerX - (block.getX() + BLOCK_WIDTH / 2) * zoom;
const offsetY = centerY - (block.getY() + BLOCK_HEIGHT / 2) * zoom;
engine.getModel().setOffset(offsetX, offsetY);
}
async function redirectToTriggeredFlow(
currentFlow: string,
triggeredFlow: string,
) {
if (currentFlow !== triggeredFlow) {
setSelectedCategoryId(triggeredFlow);
}
const triggeredFlowUrl = `/${RouterType.VISUAL_EDITOR}/flows/${triggeredFlow}`;
if (window.location.href !== triggeredFlowUrl) {
await router.push(triggeredFlow);
}
}
async function handleHighlightFlow(payload: any) {
await removeHighlights();
await redirectToTriggeredFlow(selectedCategoryId, payload.flowId);
setTimeout(() => {
const block = engine?.getModel().getNode(payload.blockId) as NodeModel;
if (!block) {
return;
}
if (!isBlockVisibleOnCanvas(block)) {
centerBlockInView(block);
}
block.setSelected(true);
block.setHighlighted(true);
engine.repaintCanvas();
}, 200);
}
async function handleHighlightErroredBlock(payload: any) {
await removeHighlights();
await redirectToTriggeredFlow(selectedCategoryId, payload.flowId);
setTimeout(() => {
const block = engine?.getModel().getNode(payload.blockId) as NodeModel;
if (!block) {
return;
}
if (!isBlockVisibleOnCanvas(block)) {
centerBlockInView(block);
}
block.setHasErrored(true);
engine.repaintCanvas();
}, 220);
}
useSubscribe("highlight:flow", handleHighlightFlow);
useSubscribe("highlight:error", handleHighlightErroredBlock);
return (
<VisualEditorContext.Provider
value={{

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 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 @@
import { BaseModelOptions } from "@projectstorm/react-canvas-core";
import {
DefaultNodeModel as StormNodeModel,
DefaultPortModel,
DefaultNodeModel as StormNodeModel,
} from "@projectstorm/react-diagrams";
import { BlockPorts } from "@/types/visual-editor.types";
@ -24,6 +24,8 @@ export interface NodeModelOptions extends BaseModelOptions {
patterns?: string[];
message?: string[];
starts_conversation?: boolean;
_isHighlighted: boolean;
_hasErrored: boolean;
}
export class NodeModel extends StormNodeModel {
@ -36,8 +38,12 @@ export class NodeModel extends StormNodeModel {
patterns: string[];
message: string[];
starts_conversation?: boolean;
_isHighlighted: boolean = false;
_hasErrored: boolean = false;
constructor(options: NodeModelOptions = {}) {
constructor(
options: NodeModelOptions = { _isHighlighted: false, _hasErrored: false },
) {
super({
...options,
type: "ts-custom-node",
@ -74,6 +80,21 @@ export class NodeModel extends StormNodeModel {
}),
);
}
setHighlighted(isHighlighted: boolean) {
this._isHighlighted = isHighlighted;
}
isHighlighted(): boolean {
return this._isHighlighted;
}
setHasErrored(hasErrored: boolean) {
this._hasErrored = hasErrored;
this.fireEvent({ hasErrored }, "stateChanged");
}
hasErrored(): boolean {
return this._hasErrored;
}
serialize() {
return {

View File

@ -14,6 +14,7 @@ import MenuRoundedIcon from "@mui/icons-material/MenuRounded";
import PlayArrowRoundedIcon from "@mui/icons-material/PlayArrowRounded";
import ReplyIcon from "@mui/icons-material/Reply";
import { Chip, styled } from "@mui/material";
import { ListenerHandle } from "@projectstorm/react-canvas-core";
import { DiagramEngine, PortWidget } from "@projectstorm/react-diagrams-core";
import clsx from "clsx";
import * as React from "react";
@ -242,6 +243,7 @@ class NodeWidget extends React.Component<
NodeWidgetProps & WithTranslation,
NodeWidgetState
> {
listener: ListenerHandle | undefined;
config: {
type: TBlock;
color: string;
@ -253,15 +255,30 @@ class NodeWidget extends React.Component<
this.config = getBlockConfig(this.props.node.message as any);
}
componentDidMount() {
this.listener = this.props.node.registerListener({
stateChanged: () => this.forceUpdate(),
});
}
componentWillUnmount() {
if (this.listener) {
this.listener.deregister();
}
}
render() {
const { t, i18n, tReady } = this.props;
const selectedStyling = clsx(
"custom-node",
this.props.node.isSelected() ? "selected" : "",
this.props.node.isHighlighted() ? "high-lighted" : "",
this.props.node.hasErrored() ? "high-light-error" : "",
);
return (
<div
className={clsx(
"custom-node",
this.props.node.isSelected() ? "selected" : "",
)}
className={selectedStyling}
style={{
border: `1px solid ${this.config.color}`,
}}

View File

@ -34,7 +34,7 @@
}
.start-point {
color: #FFF;
color: #fff;
position: absolute;
top: 50%;
left: 50%;
@ -183,7 +183,7 @@
margin-top: 5px;
border-radius: 100%;
box-shadow: 0 0 8px #0003;
transition: all .4s ease 0s;
transition: all 0.4s ease 0s;
}
.circle-out-porters {
position: absolute;
@ -194,11 +194,11 @@
.circle-porter-in {
position: absolute;
top: 50%;
transform: translateY(-50%) scale(.75);
transform: translateY(-50%) scale(0.75);
left: -12px;
}
.circle-porter-out {
transform: scale(.75);
transform: scale(0.75);
right: -12px;
}
@ -211,3 +211,61 @@
cursor: grab;
transform: translateY(-50%) scale(1.1);
}
.node:has(.high-lighted) {
box-shadow: 0 0 14px 6px rgba(0, 153, 255, 0.6);
z-index: 10;
transform: scale(1.04);
cursor: grab;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border-radius: 10px;
animation: highlightPulse 0.8s ease-out infinite;
}
@keyframes highlightPulse {
0% {
box-shadow: 0 0 0 rgba(0, 153, 255, 0.2);
transform: scale(1);
}
20% {
box-shadow: 0 0 30px 12px rgba(0, 153, 255, 1);
transform: scale(1.1);
}
60% {
box-shadow: 0 0 20px 6px rgba(0, 153, 255, 0.7);
transform: scale(1.06);
}
100% {
box-shadow: 0 0 10px 4px rgba(0, 153, 255, 0.6);
transform: scale(1.04);
}
}
.node:has(.high-light-error) {
box-shadow: 0 0 14px 6px rgba(255, 0, 0, 0.6);
z-index: 10;
transform: scale(1.04);
cursor: grab;
transition: transform 0.3s ease, box-shadow 0.3s ease;
border-radius: 10px;
animation: highlightPulseError 0.8s ease-out infinite;
}
@keyframes highlightPulseError {
0% {
box-shadow: 0 0 0 rgba(255, 0, 0, 0.2);
transform: scale(1);
}
20% {
box-shadow: 0 0 30px 12px rgba(255, 0, 0, 1);
transform: scale(1.1);
}
60% {
box-shadow: 0 0 20px 6px rgba(255, 0, 0, 0.7);
transform: scale(1.06);
}
100% {
box-shadow: 0 0 10px 4px rgba(255, 0, 0, 0.6);
transform: scale(1.04);
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright © 2024 Hexastack. All rights reserved.
* Copyright © 2025 Hexastack. All rights reserved.
*
* Licensed under the GNU Affero General Public License v3.0 (AGPLv3) with the following additional terms:
* 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) => {
const [connected, setConnected] = useState(false);
const { toast } = useToast();
const { user } = useAuth();
// todo: fix we aren't sending auth token
const socket = useMemo(() => new SocketIoClient(apiUrl), [apiUrl]);
useEffect(() => {