diff --git a/api/src/chat/schemas/types/message.ts b/api/src/chat/schemas/types/message.ts index 5c8d9bec..2c6f2fab 100644 --- a/api/src/chat/schemas/types/message.ts +++ b/api/src/chat/schemas/types/message.ts @@ -70,6 +70,8 @@ export enum FileType { export enum PayloadType { location = 'location', attachments = 'attachments', + quick_reply = 'quick_reply', + button = 'button', } export type StdOutgoingTextMessage = { text: string }; diff --git a/api/src/chat/schemas/types/quick-reply.ts b/api/src/chat/schemas/types/quick-reply.ts index 700cbb7c..df47e1a3 100644 --- a/api/src/chat/schemas/types/quick-reply.ts +++ b/api/src/chat/schemas/types/quick-reply.ts @@ -7,11 +7,7 @@ */ import { IncomingAttachmentPayload } from './attachment'; - -export enum PayloadType { - location = 'location', - attachments = 'attachments', -} +import { PayloadType } from './message'; export type Payload = | { diff --git a/api/src/utils/test/mocks/block.ts b/api/src/utils/test/mocks/block.ts index f7322e2f..1c8d3c58 100644 --- a/api/src/utils/test/mocks/block.ts +++ b/api/src/utils/test/mocks/block.ts @@ -13,10 +13,13 @@ import { import { BlockFull } from '@/chat/schemas/block.schema'; import { FileType } from '@/chat/schemas/types/attachment'; import { ButtonType } from '@/chat/schemas/types/button'; -import { OutgoingMessageFormat } from '@/chat/schemas/types/message'; +import { + OutgoingMessageFormat, + PayloadType, +} from '@/chat/schemas/types/message'; import { BlockOptions, ContentOptions } from '@/chat/schemas/types/options'; import { Pattern } from '@/chat/schemas/types/pattern'; -import { PayloadType, QuickReplyType } from '@/chat/schemas/types/quick-reply'; +import { QuickReplyType } from '@/chat/schemas/types/quick-reply'; import { CaptureVar } from '@/chat/validation-rules/is-valid-capture'; import { modelInstance } from './misc'; diff --git a/frontend/public/locales/en/translation.json b/frontend/public/locales/en/translation.json index 26366b6c..96af5d5e 100644 --- a/frontend/public/locales/en/translation.json +++ b/frontend/public/locales/en/translation.json @@ -300,6 +300,7 @@ "from_channels": "Target channels", "simple_text": "Simple Text", "quick_replies": "Quick Replies", + "button": "Button", "buttons": "Buttons", "web_url": "Web URL", "payload": "Payload", diff --git a/frontend/public/locales/fr/translation.json b/frontend/public/locales/fr/translation.json index cffef81a..ab90512f 100644 --- a/frontend/public/locales/fr/translation.json +++ b/frontend/public/locales/fr/translation.json @@ -300,6 +300,7 @@ "from_channels": "Cibler les canaux", "simple_text": "Texte simple", "quick_replies": "Réponses rapides", + "button": "Boutton", "buttons": "Boutons", "web_url": "URL Web", "payload": "Payload", diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/ContentPostbackInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/ContentPostbackInput.tsx deleted file mode 100644 index a297d12e..00000000 --- a/frontend/src/components/visual-editor/form/inputs/triggers/ContentPostbackInput.tsx +++ /dev/null @@ -1,113 +0,0 @@ -/* - * 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 { Box, Typography } from "@mui/material"; -import { useMemo } from "react"; - -import AutoCompleteSelect from "@/app-components/inputs/AutoCompleteSelect"; -import { useFind } from "@/hooks/crud/useFind"; -import { useGetFromCache } from "@/hooks/crud/useGet"; -import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType, Format } from "@/services/types"; -import { IBlock, PayloadPattern } from "@/types/block.types"; - -import { useBlock } from "../../BlockFormProvider"; - -type ContentPayloadOption = { - id: string; - label: string; -}; - -type ContentPostbackInputProps = { - value?: string | null; - onChange: (pattern: PayloadPattern) => void; -}; - -export const ContentPostbackInput = ({ - value, - onChange, -}: ContentPostbackInputProps) => { - const { t } = useTranslate(); - const block = useBlock(); - const { data: contents } = useFind( - { entity: EntityType.CONTENT, format: Format.FULL }, - { - hasCount: false, - }, - ); - const getBlockFromCache = useGetFromCache(EntityType.BLOCK); - const options = useMemo( - () => - (block?.previousBlocks || []) - .map((bId) => getBlockFromCache(bId)) - .filter((b) => { - return ( - b && - b.options?.content?.entity && - b.options.content.buttons.length > 0 - ); - }) - .map((b) => b as IBlock) - .map((b) => { - const availableContents = (contents || []).filter( - ({ entity, status }) => - status && entity === b.options?.content?.entity, - ); - - return (b.options?.content?.buttons || []).reduce((payloads, btn) => { - // Return a payload for each node/button combination - payloads.push({ - id: btn.title, - label: btn.title, - }); - - return availableContents.reduce((acc, n) => { - acc.push({ - id: btn.title + ":" + n.title, - label: btn.title + ":" + n.title, - }); - - return acc; - }, payloads); - }, [] as ContentPayloadOption[]); - }) - .flat(), - [block?.previousBlocks, contents, getBlockFromCache], - ); - - return ( - - value={value} - options={options} - labelKey="label" - label={t("label.content")} - multiple={false} - onChange={(_e, content) => { - content && - onChange({ - label: content.label, - value: content.id, - type: "content", - } as PayloadPattern); - }} - groupBy={(option) => { - const [btn] = option.label.split(":"); - - return btn; - }} - renderGroup={(params) => ( -
  • - - {params.group} - - {params.children} -
  • - )} - /> - ); -}; diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx index 0bab40f0..729fea4c 100644 --- a/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx +++ b/frontend/src/components/visual-editor/form/inputs/triggers/PatternInput.tsx @@ -10,12 +10,10 @@ import { Grid, MenuItem, TextFieldProps } from "@mui/material"; import { FC, useEffect, useState } from "react"; import { RegisterOptions, useFormContext } from "react-hook-form"; -import AutoCompleteEntitySelect from "@/app-components/inputs/AutoCompleteEntitySelect"; import { Input } from "@/app-components/inputs/Input"; import NlpPatternSelect from "@/app-components/inputs/NlpPatternSelect"; import { RegexInput } from "@/app-components/inputs/RegexInput"; import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType, Format } from "@/services/types"; import { IBlockAttributes, IBlockFull, @@ -24,9 +22,7 @@ import { PatternType, PayloadPattern, } from "@/types/block.types"; -import { IMenuItem } from "@/types/menu.types"; -import { ContentPostbackInput } from "./ContentPostbackInput"; import { PostbackInput } from "./PostbackInput"; const isRegex = (str: Pattern) => { @@ -72,13 +68,12 @@ const PatternInput: FC = ({ // const getNlpEntityFromCache = useGetFromCache(EntityType.NLP_ENTITY); const [pattern, setPattern] = useState(value); const [patternType, setPatternType] = useState(getType(value)); + const isPostbackType = ["payload", "content", "menu"].includes(patternType); const types = [ { value: "text", label: t("label.match_sound") }, { value: "regex", label: t("label.regex") }, { value: "payload", label: t("label.postback") }, { value: "nlp", label: t("label.nlp") }, - { value: "menu", label: t("label.menu") }, - { value: "content", label: t("label.content") }, ]; const registerInput = ( errorMessage: string, @@ -110,7 +105,7 @@ const PatternInput: FC = ({ { const selected = e.target.value as PatternType; @@ -151,43 +146,12 @@ const PatternInput: FC = ({ onChange={setPattern} /> )} - {patternType === "menu" ? ( - - value={pattern ? (pattern as PayloadPattern).value : null} - searchFields={["title"]} - entity={EntityType.MENU} - format={Format.BASIC} - idKey="payload" - labelKey="title" - label={t("label.menu")} - multiple={false} - onChange={(_e, menuItem) => { - menuItem && - setPattern({ - label: menuItem?.title, - value: menuItem?.payload, - type: "menu", - } as PayloadPattern); - }} - preprocess={(items) => { - return items.filter((item) => "payload" in item); - }} - /> - ) : null} - {patternType === "content" ? ( - { - payload && setPattern(payload); - }} - value={pattern ? (pattern as PayloadPattern).value : null} - /> - ) : null} - {patternType === "payload" ? ( + {isPostbackType ? ( { payload && setPattern(payload); }} - value={pattern ? (pattern as PayloadPattern).value : null} + defaultValue={pattern as PayloadPattern} /> ) : null} {typeof value === "string" && patternType === "regex" ? ( diff --git a/frontend/src/components/visual-editor/form/inputs/triggers/PostbackInput.tsx b/frontend/src/components/visual-editor/form/inputs/triggers/PostbackInput.tsx index 5fbd96b1..9ac684d6 100644 --- a/frontend/src/components/visual-editor/form/inputs/triggers/PostbackInput.tsx +++ b/frontend/src/components/visual-editor/form/inputs/triggers/PostbackInput.tsx @@ -6,16 +6,29 @@ * 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 { Box, Typography } from "@mui/material"; -import { useMemo } from "react"; +import { + Autocomplete, + Box, + Chip, + CircularProgress, + InputAdornment, + Skeleton, + Typography, +} from "@mui/material"; +import { useMemo, useState } from "react"; -import AutoCompleteSelect from "@/app-components/inputs/AutoCompleteSelect"; +import { Input } from "@/app-components/inputs/Input"; +import { useFind } from "@/hooks/crud/useFind"; import { useGetFromCache } from "@/hooks/crud/useGet"; import { useTranslate } from "@/hooks/useTranslate"; -import { EntityType } from "@/services/types"; +import { theme } from "@/layout/themes/theme"; +import { EntityType, Format } from "@/services/types"; import { IBlock, PayloadPattern } from "@/types/block.types"; import { + ButtonType, + PayloadType, PostBackButton, + QuickReplyType, StdOutgoingButtonsMessage, StdOutgoingQuickRepliesMessage, StdQuickReply, @@ -23,38 +36,56 @@ import { import { useBlock } from "../../BlockFormProvider"; -type PayloadOption = { - id: string; - label: string; - group?: string; +type PayloadOption = PayloadPattern & { + group: string; }; +const isSamePostback = (a: T, b: T) => + a.label === b.label && a.value === b.value; + type PostbackInputProps = { - value?: string | null; - onChange: (pattern: PayloadPattern) => void; + defaultValue?: PayloadPattern; + onChange: (pattern: PayloadPattern | null) => void; }; -export const PostbackInput = ({ value, onChange }: PostbackInputProps) => { +export const PostbackInput = ({ + defaultValue, + onChange, +}: PostbackInputProps) => { const block = useBlock(); + const [selectedValue, setSelectedValue] = useState(defaultValue || null); const getBlockFromCache = useGetFromCache(EntityType.BLOCK); + const { data: menu, isLoading: isLoadingMenu } = useFind( + { entity: EntityType.MENU, format: Format.FULL }, + { hasCount: false }, + ); + const { data: contents, isLoading: isLoadingContent } = useFind( + { entity: EntityType.CONTENT, format: Format.FULL }, + { + hasCount: false, + }, + ); const { t } = useTranslate(); // General options const generalOptions = [ { - id: "GET_STARTED", label: t("label.get_started"), - group: t("label.general"), + value: "GET_STARTED", + type: PayloadType.button, + group: "general", }, { - id: "VIEW_MORE", label: t("label.view_more"), - group: t("label.general"), + value: "VIEW_MORE", + type: PayloadType.button, + group: "general", }, { - id: "LOCATION", label: t("label.location"), - group: t("label.general"), + value: "LOCATION", + type: PayloadType.location, + group: "general", }, ]; // Gather previous blocks buttons @@ -62,28 +93,25 @@ export const PostbackInput = ({ value, onChange }: PostbackInputProps) => { () => (block?.previousBlocks || []) .map((b) => getBlockFromCache(b)) - .filter((b) => { - return b && typeof b.message === "object" && "buttons" in b.message; - }) + .filter( + (b) => b && typeof b.message === "object" && "buttons" in b.message, + ) .map((b) => b as IBlock) .reduce((acc, b) => { const postbackButtons = ( (b.message as StdOutgoingButtonsMessage)?.buttons || [] ) - .filter((btn) => btn.type === "postback") - .map((btn) => { - return { ...btn, group: b.name }; - }); + .filter((btn) => btn.type === ButtonType.postback) + .map((btn) => ({ ...btn, group: b.name })); return acc.concat(postbackButtons); }, [] as (PostBackButton & { group: string })[]) - .map((btn) => { - return { - id: btn.payload, - label: btn.title, - group: btn.group, - }; - }), + .map((btn) => ({ + label: btn.title, + value: btn.payload, + type: PayloadType.button, + group: "button", + })), [block?.previousBlocks, getBlockFromCache], ); // Gather previous blocks quick replies @@ -91,62 +119,157 @@ export const PostbackInput = ({ value, onChange }: PostbackInputProps) => { () => (block?.previousBlocks || []) .map((b) => getBlockFromCache(b)) - .filter((b) => { - return ( - b && typeof b.message === "object" && "quickReplies" in b.message - ); - }) + .filter( + (b) => + b && typeof b.message === "object" && "quickReplies" in b.message, + ) .map((b) => b as IBlock) .reduce((acc, b) => { const postbackQuickReplies = ( (b.message as StdOutgoingQuickRepliesMessage)?.quickReplies || [] ) - .filter((btn) => btn.content_type === "text") - .map((btn) => { - return { ...btn, group: b.name }; - }); + .filter(({ content_type }) => content_type === QuickReplyType.text) + .map((btn) => ({ ...btn, group: b.name })); return acc.concat(postbackQuickReplies); }, [] as (StdQuickReply & { group: string })[]) - .map((btn) => { - return { - id: btn.payload as string, - label: btn.title as string, - group: btn.group, - }; - }), + .map((btn) => ({ + label: btn.title as string, + value: btn.payload as string, + type: PayloadType.quick_reply, + group: "quick_reply", + })), [block?.previousBlocks], ); + const menuOptions = menu + .filter(({ payload }) => payload) + .map(({ title, payload }) => ({ + label: title, + value: payload as string, + type: PayloadType.menu, + group: "menu", + })); + const contentOptions = useMemo( + () => + (block?.previousBlocks || []) + .map((bId) => getBlockFromCache(bId) as IBlock) + .filter( + (b) => + b && + b.options?.content?.entity && + b.options.content.buttons.length > 0, + ) + .map((b) => { + const availableContents = (contents || []).filter( + ({ entity, status }) => + status && entity === b.options?.content?.entity, + ); + + return (b.options?.content?.buttons || []).reduce((payloads, btn) => { + // Return a payload for each node/button combination + payloads.push({ + label: btn.title, + value: btn.title, + type: PayloadType.content, + group: "content", + }); + + return availableContents.reduce((acc, n) => { + acc.push({ + label: n.title, + value: n.title, + type: PayloadType.content, + group: "content", + }); + + return acc; + }, payloads); + }, [] as PayloadOption[]); + }) + .flat(), + [block?.previousBlocks, contents, getBlockFromCache], + ); // Concat all previous blocks - const options = [...generalOptions, ...btnOptions, ...qrOptions]; + const options: PayloadOption[] = [ + ...generalOptions, + ...btnOptions, + ...qrOptions, + ...menuOptions, + ...contentOptions, + ]; + const isOptionsReady = + !defaultValue || options.find((o) => isSamePostback(o, defaultValue)); + + if (!isOptionsReady || isLoadingContent || isLoadingMenu) { + return ( + + ); + } + const selected = defaultValue + ? options.find((o) => isSamePostback(o, defaultValue)) + : undefined; return ( - <> - - value={value} - options={options} - labelKey="label" - label={t("label.postback")} - multiple={false} - onChange={(_e, content) => { - content && - onChange({ - label: content.label, - value: content.id, - } as PayloadPattern); - }} - groupBy={(option) => { - return option.group ?? t("label.other"); - }} - renderGroup={(params) => ( -
  • - - {params.group} - - {params.children} -
  • - )} - /> - + { + setSelectedValue(value); + if (value) { + const { group: _g, ...payloadPattern } = value; + + onChange(payloadPattern); + } else { + onChange(null); + } + }} + groupBy={({ group }) => group ?? t("label.other")} + getOptionLabel={({ label }) => label} + renderGroup={({ key, group, children }) => ( +
  • + + {t(`label.${group}`)} + + {children} +
  • + )} + renderInput={(props) => ( + + + + ), + endAdornment: + isLoadingMenu || isLoadingContent ? ( + + ) : null, + }} + /> + )} + /> ); }; diff --git a/frontend/src/types/block.types.ts b/frontend/src/types/block.types.ts index 5eb686d6..c8825a3f 100644 --- a/frontend/src/types/block.types.ts +++ b/frontend/src/types/block.types.ts @@ -66,6 +66,8 @@ export interface PayloadPattern { label: string; 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 type?: PayloadType; } diff --git a/frontend/src/types/message.types.ts b/frontend/src/types/message.types.ts index a1b9dea6..50b5fb7d 100644 --- a/frontend/src/types/message.types.ts +++ b/frontend/src/types/message.types.ts @@ -28,6 +28,8 @@ export enum PayloadType { attachments = "attachments", menu = "menu", content = "content", + quick_reply = "quick_reply", + button = "button", } export enum FileType {