hexabot/frontend/src/components/visual-editor/v2/CustomDiagramNodes/NodeWidget.tsx
2024-12-02 17:13:37 +01:00

441 lines
14 KiB
TypeScript

/*
* Copyright © 2024 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.
* 2. All derivative works must include clear attribution to the original creator and software, Hexastack and Hexabot, in a prominent location (e.g., in the software's "About" section, documentation, and README file).
*/
import BrokenImageOutlinedIcon from "@mui/icons-material/BrokenImageOutlined";
import ChatBubbleOutlineOutlinedIcon from "@mui/icons-material/ChatBubbleOutlineOutlined";
import ExtensionOutlinedIcon from "@mui/icons-material/ExtensionOutlined";
import FastForwardRoundedIcon from "@mui/icons-material/FastForwardRounded";
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 { DiagramEngine, PortWidget } from "@projectstorm/react-diagrams-core";
import clsx from "clsx";
import * as React from "react";
import { FC } from "react";
import { withTranslation, WithTranslation } from "react-i18next";
import { UnifiedIcon } from "@/app-components/icons/UnifiedIcon";
import AttachmentIcon from "@/app-components/svg/toolbar/AttachmentIcon";
import ButtonsIcon from "@/app-components/svg/toolbar/ButtonsIcon";
import ListIcon from "@/app-components/svg/toolbar/ListIcon";
import PluginIcon from "@/app-components/svg/toolbar/PluginIcon";
import QuickRepliesIcon from "@/app-components/svg/toolbar/QuickRepliesIcon";
import SimpleTextIcon from "@/app-components/svg/toolbar/SimpleTextIcon";
import TriggerIcon from "@/app-components/svg/TriggerIcon";
import { IBlockFull, Pattern } from "@/types/block.types";
import { BlockPorts, BlockTypes, TBlock } from "@/types/visual-editor.types";
import { truncate } from "@/utils/text";
import { NodeModel } from "./NodeModel";
export const BLOCK_WIDTH = 360;
export const BLOCK_HEIGHT = 200;
export interface NodeWidgetProps {
node: NodeModel;
engine: DiagramEngine;
color: string;
}
const IconContainer = styled("div")(({ theme }) => ({
display: "flex",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.palette.background.paper,
borderRadius: "100%",
padding: "1px",
}));
export interface NodeWidgetState {}
class NodeAbstractWidget extends React.Component<
NodeWidgetProps & WithTranslation,
NodeWidgetState
> {
constructor(props: NodeWidgetProps & WithTranslation) {
super(props);
this.state = {};
}
render() {
return (
<div className={clsx("custom-node-port")}>
{!this.props.node.source ? (
<PortWidget
engine={this.props.engine}
//TODO: need to be updated
// @ts-ignore
port={this.props.node.getPort(BlockPorts.inPort)}
className={clsx("circle-porter", "circle-porter-in")}
>
<IconContainer
style={{
borderWidth: "3px",
borderColor: this.props.color,
borderStyle: "solid",
}}
>
<PlayArrowRoundedIcon
sx={{
color: this.props.color,
fontSize: 23,
}}
/>
</IconContainer>
</PortWidget>
) : null}
{this.props.node.content}
<div className="circle-out-porters">
{Object.entries(this.props.node.getPorts())
//TODO: need to be updated
// @ts-ignore
.filter(([, value]) => !value.options.in)
.map(([, value], index) => (
<PortWidget
//TODO: need to be updated
// @ts-ignore
key={value.options.id}
engine={this.props.engine}
//TODO: need to be updated
// @ts-ignore
port={this.props.node.getPort(value.options.name)}
className={clsx(
"circle-porter",
"circle-porter-out",
//TODO: need to be updated
// @ts-ignore
this.props.node.getPort(value.options.name)?.isLocked()
? "circle-porter-out-off"
: "",
)}
>
<IconContainer
style={{
borderWidth: "3px",
borderColor: this.props.color,
borderStyle: "solid",
}}
>
{index === 0 ? (
<PlayArrowRoundedIcon
sx={{
color: this.props.color,
fontSize: 23,
}}
/>
) : (
<FastForwardRoundedIcon
sx={{
color: this.props.color,
fontSize: 23,
}}
/>
)}
</IconContainer>
</PortWidget>
))}
</div>
</div>
);
}
}
class NodeFunctionWidget extends React.Component<
NodeWidgetProps & WithTranslation,
NodeWidgetState
> {
constructor(props: NodeWidgetProps & WithTranslation) {
super(props);
this.state = {};
}
render() {
const { t } = this.props;
return (
<>
{this.props.node.inputs.length > 0 ? (
<div className="custom-node-subheader">{t("label.inputs")}</div>
) : null}
{this.props.node.inputs.map((input, index) => (
<div
key={"i-" + index}
className={clsx("custom-node-port", "custom-node-port-in")}
>
<PortWidget
engine={this.props.engine}
//TODO: need to be updated
// @ts-ignore
port={this.props.node.getPort(input)}
className={clsx("circle-porter", "circle-porter-in")}
>
<div className={clsx("circle-port")} />
</PortWidget>
{input}
</div>
))}
{this.props.node.outputs.length > 0 ? (
<div className="custom-node-subheader">Outputs</div>
) : null}
{this.props.node.outputs.map((output, index) => (
<div
key={"o-" + index}
className={clsx("custom-node-port", "custom-node-port-out")}
>
{output}
<PortWidget
engine={this.props.engine}
//TODO: need to be updated
// @ts-ignore
port={this.props.node.getPort(output)}
className={clsx("circle-porter", "circle-porter-out")}
>
<div className={clsx("circle-port")} />
</PortWidget>
</div>
))}
</>
);
}
}
function determineCase(blockMessage: IBlockFull["message"]) {
if (typeof blockMessage === "string" || Array.isArray(blockMessage))
return "text";
if ("attachment" in blockMessage) return "attachment";
if ("quickReplies" in blockMessage) return "quickReplies";
if ("buttons" in blockMessage) return "buttons";
if ("elements" in blockMessage) return "list";
return "plugin";
}
const getBlockConfig = (
blockMessage: IBlockFull["message"],
): { type: TBlock; color: string; Icon: FC<React.SVGProps<SVGSVGElement>> } => {
switch (determineCase(blockMessage)) {
case "text":
return { type: "text", color: "#009185", Icon: SimpleTextIcon };
case "attachment":
return { type: "attachment", color: "#e6a23c", Icon: AttachmentIcon };
case "quickReplies":
return { type: "quickReplies", color: "#a80551", Icon: QuickRepliesIcon };
case "buttons":
return { type: "buttons", color: "#570063", Icon: ButtonsIcon };
case "list":
return { type: "list", color: "#108aa8", Icon: ListIcon };
case "plugin":
return { type: "plugin", color: "#a8ba33", Icon: PluginIcon };
default:
throw new Error("Unexpected case");
}
};
class NodeWidget extends React.Component<
NodeWidgetProps & WithTranslation,
NodeWidgetState
> {
config: {
type: TBlock;
color: string;
Icon: FC<React.SVGProps<SVGSVGElement>>;
};
constructor(props: NodeWidgetProps & WithTranslation) {
super(props);
this.state = {};
this.config = getBlockConfig(this.props.node.message as any);
}
render() {
const { t, i18n, tReady } = this.props;
return (
<div
className={clsx(
"custom-node",
this.props.node.isSelected() ? "selected" : "",
)}
style={{
border: `6px solid ${this.config.color}`,
}}
>
{this.props.node.starts_conversation ? (
<div className="start-point-container">
<div className="start-point" />
</div>
) : null}
<div
className="node-title"
style={{
backgroundColor: this.config.color,
}}
>
<div
style={{
display: "flex",
backgroundColor: "white",
borderRadius: "100%",
padding: "2px",
}}
>
<this.config.Icon width={32} height={32} />
</div>
{this.props.node.title}
</div>
<div className="node-block-field-container">
<div className="node-block-field">
<TriggerIcon color={this.config.color} />
{this.props.node.patterns.length > 0 ? (
this.props.node.patterns
.map((pattern: Pattern) => {
if (typeof pattern === "string") {
return pattern;
} else if (typeof pattern === "object") {
if (pattern && "label" in pattern) {
return pattern.label;
} else if (Array.isArray(pattern)) {
return pattern
.map((p) => {
return `${p.entity}=${"value" in p ? p.value : "*"}`;
})
.join(" & ");
}
}
})
.map((p, idx) => (
<Chip
variant="inbox"
size="medium"
key={`${p}_${idx}`}
label={p}
/>
))
) : (
<span style={{ color: "#939393" }}>{t("label.no_patterns")}</span>
)}
</div>
{[BlockTypes.attachment].includes(BlockTypes[this.config.type]) ? (
<div className="node-block-field">
<UnifiedIcon
Icon={BrokenImageOutlinedIcon}
color={this.config.color}
size="21px"
/>
{t("label.attachment")}:{" "}
{(this.props.node.message as any).attachment.type}
</div>
) : null}
{[BlockTypes.plugin].includes(BlockTypes[this.config.type]) ? (
<div className="node-block-field ">
<UnifiedIcon
Icon={ExtensionOutlinedIcon}
color={this.config.color}
size="21px"
/>
<span>Plugin: {(this.props.node.message as any).plugin}</span>
</div>
) : null}
{[BlockTypes.list].includes(BlockTypes[this.config.type]) ? (
<div className="node-block-field ">
<UnifiedIcon
Icon={ChatBubbleOutlineOutlinedIcon}
color={this.config.color}
size="21px"
/>
{t("label.list")}
</div>
) : null}
{[BlockTypes.text].includes(BlockTypes[this.config.type]) &&
this.props.node.message.length ? (
<div className="node-block-field ">
<UnifiedIcon
Icon={ChatBubbleOutlineOutlinedIcon}
color={this.config.color}
size="21px"
/>
{truncate(this.props.node.message[0])}
</div>
) : null}
{[BlockTypes.quickReplies, BlockTypes.buttons].includes(
BlockTypes[this.config.type],
) ? (
<div className="node-block-field ">
<UnifiedIcon
Icon={ChatBubbleOutlineOutlinedIcon}
color={this.config.color}
size="21px"
/>
{
//TODO: need to be updated
// @ts-ignore
truncate(this.props.node.message.text)
}
</div>
) : null}
{[BlockTypes.quickReplies, BlockTypes.buttons].includes(
BlockTypes[this.config.type],
) ? (
<div className="node-block-field">
<UnifiedIcon
Icon={
this.config.type === "quickReplies"
? ReplyIcon
: MenuRoundedIcon
}
color={this.config.color}
size="21px"
/>
<div className="node-block-chips">
{
//TODO: need to be updated
// @ts-ignore
(
(this.props.node.message as any).buttons ||
(this.props.node.message as any).quickReplies
).map((button, idx: number) => (
<Chip
key={`${button.title}_${idx}`}
variant="inbox"
size="medium"
label={button.title}
/>
))
}
</div>
</div>
) : null}{" "}
</div>
{this.props.node.content ? (
<NodeAbstractWidget
node={this.props.node}
engine={this.props.engine}
color={this.config.color}
t={t}
i18n={i18n}
tReady={tReady}
/>
) : (
<NodeFunctionWidget
node={this.props.node}
engine={this.props.engine}
color={this.config.color}
t={t}
i18n={i18n}
tReady={tReady}
/>
)}
</div>
);
}
}
export default withTranslation()(NodeWidget);