feat: add ability to select an outcome in any block triggers

This commit is contained in:
Mohamed Marrouchi 2025-03-03 09:55:23 +01:00
parent 7e48eb6067
commit 34c299337d
11 changed files with 228 additions and 29 deletions

View File

@ -247,6 +247,13 @@
"triggers": "Triggers",
"payloads": "Payloads",
"general_payloads": "General Payloads",
"exact_match": "Exact Match",
"pattern_match": "Pattern Match",
"intent_match": "Intent Match",
"interaction": "Interaction",
"outcome_match": "Outcome Match",
"outcome": "Outcome",
"any_outcome": "Any Outcome",
"capture": "Capture?",
"context_var": "Context Var",
"text_message": "Text message",

View File

@ -247,6 +247,13 @@
"triggers": "Déclencheurs",
"payloads": "Payloads",
"general_payloads": "Payloads généraux",
"exact_match": "Comparaison Exacte",
"pattern_match": "Expression Régulière",
"intent_match": "Intention",
"interaction": "Interaction",
"outcome": "Résultat",
"outcome_match": "Résultat",
"any_outcome": "N'importe quel résultat",
"capture": "Capturer?",
"context_var": "Variable contextuelle",
"text_message": "Message texte",

View File

@ -59,6 +59,7 @@ export const BlockEditForm: FC<ComponentFormProps<IBlock>> = ({
const DEFAULT_VALUES = {
name: block?.name || "",
patterns: block?.patterns || [],
outcomes: block?.outcomes || [],
trigger_labels: block?.trigger_labels || [],
trigger_channels: block?.trigger_channels || [],
options: block?.options || {

View File

@ -17,6 +17,7 @@ import { EntityType } from "@/services/types";
import { IBlockAttributes } from "@/types/block.types";
import { StdPluginMessage } from "@/types/message.types";
import { getNamespace } from "@/utils/string";
import { useBlock } from "./BlockFormProvider";
const PluginMessageForm = () => {
@ -63,8 +64,7 @@ const PluginMessageForm = () => {
<SettingInput
setting={setting}
field={field}
// @TODO : clean this later
ns={message.plugin.replaceAll("-", "_")}
ns={getNamespace(message.plugin)}
/>
</FormControl>
)}

View File

@ -0,0 +1,151 @@
/*
* 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 {
Autocomplete,
Box,
Chip,
InputAdornment,
Skeleton,
Typography,
} from "@mui/material";
import { useMemo, useState } from "react";
import { Input } from "@/app-components/inputs/Input";
import { useGetFromCache } from "@/hooks/crud/useGet";
import { useTranslate } from "@/hooks/useTranslate";
import { theme } from "@/layout/themes/theme";
import { EntityType } from "@/services/types";
import { IBlock, PayloadPattern } from "@/types/block.types";
import { PayloadType } from "@/types/message.types";
import { getNamespace } from "@/utils/string";
import { useBlock } from "../../BlockFormProvider";
type PayloadOption = PayloadPattern & {
group: string;
};
type OutcomeInputProps = {
defaultValue: PayloadPattern;
onChange: (pattern: PayloadPattern) => void;
};
export const OutcomeInput = ({ defaultValue, onChange }: OutcomeInputProps) => {
const block = useBlock();
const [selectedValue, setSelectedValue] = useState(defaultValue);
const getBlockFromCache = useGetFromCache(EntityType.BLOCK);
const { t } = useTranslate();
// Gather previous blocks outcomes
const options = useMemo(
() =>
(block?.previousBlocks || [])
.map((b) => getBlockFromCache(b))
.filter((b) => b && Array.isArray(b.outcomes) && b.outcomes.length > 0)
.map((b) => b as IBlock)
.reduce(
(acc, b) => {
const outcomes = (b.outcomes || []).map((outcome) => ({
label: t(`label.${outcome}` as any, {
defaultValue: outcome,
ns:
"plugin" in b.message
? getNamespace(b.message.plugin)
: undefined,
}),
value: outcome,
group: b.name,
type: PayloadType.outcome,
}));
return acc.concat(outcomes);
},
[
{
label: t("label.any_outcome"),
value: "any",
type: PayloadType.outcome,
group: "general",
},
] as PayloadOption[],
),
[block?.previousBlocks, getBlockFromCache],
);
const isOptionsReady =
!defaultValue || options.find((o) => o.value === defaultValue.value);
if (!isOptionsReady) {
return (
<Skeleton animation="wave" variant="rounded" width="100%" height={40} />
);
}
const selected = defaultValue
? options.find((o) => o.value === defaultValue.value)
: undefined;
return (
<Autocomplete
size="small"
fullWidth
defaultValue={selected || undefined}
value={selected}
options={options}
multiple={false}
disableClearable
onChange={(_e, value) => {
setSelectedValue(value);
const { group: _g, ...payloadPattern } = value;
onChange(payloadPattern);
}}
groupBy={({ group }) => group ?? t("label.other")}
getOptionLabel={({ label }) => label}
isOptionEqualToValue={(option, value) => option.value === value.value}
renderGroup={({ key, group, children }) => (
<li key={key}>
<Typography component="h4" p={2} fontWeight={700} color="primary">
{t(`label.${group}`, { defaultValue: group })}
</Typography>
<Box>{children}</Box>
</li>
)}
renderInput={(props) => (
<Input
{...props}
label={t("label.outcome")}
InputProps={{
...props.InputProps,
startAdornment: (
<InputAdornment position="start">
<Chip
sx={{
left: "8px",
height: "25px",
fontSize: "12px",
minWidth: "75px",
position: "relative",
maxHeight: "30px",
borderRadius: "16px",
borderColor: theme.palette.grey[400],
}}
color="primary"
label={t(
`label.${selectedValue?.type || "outcome"}`,
).toLocaleLowerCase()}
variant="role"
/>
</InputAdornment>
),
}}
/>
)}
/>
);
};

View File

@ -23,6 +23,7 @@ import {
PayloadPattern,
} from "@/types/block.types";
import { OutcomeInput } from "./OutcomeInput";
import { PostbackInput } from "./PostbackInput";
const isRegex = (str: Pattern) => {
@ -38,6 +39,8 @@ const getType = (pattern: Pattern): PatternType => {
return "menu";
} else if (pattern?.type === "content") {
return "content";
} else if (pattern?.type === "outcome") {
return "outcome";
} else {
return "payload";
}
@ -67,7 +70,6 @@ const PatternInput: FC<PatternInputProps> = ({
} = useFormContext<IBlockAttributes>();
const [pattern, setPattern] = useState<Pattern>(value);
const patternType = getType(value);
const isPostbackType = ["payload", "content", "menu"].includes(patternType);
const registerInput = (
errorMessage: string,
idx: number,
@ -100,15 +102,22 @@ const PatternInput: FC<PatternInputProps> = ({
onChange={setPattern}
/>
)}
{isPostbackType ? (
<PostbackInput
onChange={(payload) => {
payload && setPattern(payload);
}}
defaultValue={pattern as PayloadPattern}
/>
) : null}
{["payload", "content", "menu"].includes(patternType) ? (
<PostbackInput
onChange={(payload) => {
payload && setPattern(payload);
}}
defaultValue={pattern as PayloadPattern}
/>
) : null}
{patternType === "outcome" ? (
<OutcomeInput
onChange={(payload) => {
payload && setPattern(payload);
}}
defaultValue={pattern as PayloadPattern}
/>
) : null}
{typeof value === "string" && patternType === "regex" ? (
<RegexInput
{...registerInput(t("message.regex_is_empty"), idx, {

View File

@ -6,14 +6,13 @@
* 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 {
Abc,
Add,
Mouse,
PsychologyAlt,
RemoveCircleOutline,
Spellcheck,
} from "@mui/icons-material";
import AbcIcon from "@mui/icons-material/Abc";
import AddIcon from "@mui/icons-material/Add";
import MediationIcon from "@mui/icons-material/Mediation";
import MouseIcon from "@mui/icons-material/Mouse";
import PsychologyAltIcon from "@mui/icons-material/PsychologyAlt";
import RemoveCircleOutlineIcon from "@mui/icons-material/RemoveCircleOutline";
import SpellcheckIcon from "@mui/icons-material/Spellcheck";
import { Box, Chip, IconButton, styled, useTheme } from "@mui/material";
import { FC, useEffect, useMemo, useState } from "react";
import { useFormContext } from "react-hook-form";
@ -79,12 +78,20 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
const actions: DropdownButtonAction[] = useMemo(
() => [
{ icon: <Spellcheck />, name: "Exact Match", defaultValue: "" },
{ icon: <Abc />, name: "Pattern Match", defaultValue: "//" },
{ icon: <PsychologyAlt />, name: "Intent Match", defaultValue: [] },
{
icon: <Mouse />,
name: "Interaction",
icon: <SpellcheckIcon />,
name: t("label.exact_match"),
defaultValue: "",
},
{ icon: <AbcIcon />, name: t("label.pattern_match"), defaultValue: "//" },
{
icon: <PsychologyAltIcon />,
name: t("label.intent_match"),
defaultValue: [],
},
{
icon: <MouseIcon />,
name: t("label.interaction"),
defaultValue: {
label: t("label.get_started"),
value: "GET_STARTED",
@ -92,6 +99,16 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
group: "general",
},
},
{
icon: <MediationIcon />,
name: t("label.outcome_match"),
defaultValue: {
label: t("label.any_outcome"),
value: "any",
type: PayloadType.outcome,
group: "general",
},
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
@ -129,7 +146,7 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
color="error"
onClick={() => removeInput(idx)}
>
<RemoveCircleOutline />
<RemoveCircleOutlineIcon />
</IconButton>
</Box>
))
@ -140,7 +157,7 @@ const PatternsInput: FC<PatternsInputProps> = ({ value, onChange }) => {
label={t("button.add_pattern")}
actions={actions}
onClick={(action) => addInput(action.defaultValue as Pattern)}
icon={<Add />}
icon={<AddIcon />}
/>
</Box>
);

View File

@ -256,7 +256,7 @@ const VisualEditorProvider: React.FC<VisualEditorContextProps> = ({
const createNode = (payload: any) => {
payload.position = payload.position || getCentroid();
payload.category = payload.category || selectedCategoryId;
console.log("====", payload);
return createBlock(payload, {
onSuccess({ id }) {
addNode({

View File

@ -64,7 +64,7 @@ export interface PayloadPattern {
value: string;
// @todo : rename 'attachment' to 'attachments'
// @todo: If undefined, that means the payload could be either quick_reply or button
// We will move soon so that it will be a required attribute
// We should update soon so that it will be a required attribute
type?: PayloadType;
}
@ -81,12 +81,14 @@ export type PatternType =
| "nlp"
| "menu"
| "content"
| "outcome"
| "payload"
| "text";
export interface IBlockAttributes {
name: string;
patterns?: Pattern[];
outcomes?: string[];
trigger_labels?: string[];
trigger_channels?: string[];
assign_labels?: string[];

View File

@ -30,6 +30,7 @@ export enum PayloadType {
content = "content",
quick_reply = "quick_reply",
button = "button",
outcome = "outcome",
}
export enum FileType {

View File

@ -14,3 +14,7 @@ export const slugify = (str: string) => {
.replace(/\s+/g, "-")
.replace(/-+/g, "_");
};
export const getNamespace = (extensionName: string) => {
return extensionName.replaceAll("-", "_");
};