Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 44 additions & 19 deletions apps/builder/src/features/flows/react-flow/nodes/editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
MAX_QUICK_REPLIES,
stepTypes,
upgradeNodeSteps,
nodeTypeSchema
} from "@chatbotx.io/flow-config"
import { channelTypes } from "@chatbotx.io/utils/channel"
import { TriggerFormInitially } from "@chatbotx.io/ui/components/form/form-trigger-initially"
import { Button } from "@chatbotx.io/ui/components/ui/button"
import {
Expand Down Expand Up @@ -38,7 +40,7 @@ import {
useWatch,
} from "react-hook-form"
import { useCustomFieldStore } from "@/features/custom-fields/provider/custom-field-store-context"
import { useInboxStore } from "@/features/inboxes/provider/inbox-store-context"
import { useInboxesState } from "@/features/inboxes/provider/inbox-hook"
import RecursiveDropdownMenu from "../components/recursive-dropdown-menu"
import { allSteps, DynamicStepEditor } from "../steps"
import { ButtonStepEditor } from "../steps/button/editor"
Expand Down Expand Up @@ -162,25 +164,42 @@ const NodeEditorMenu = memo(
onClick: (menuItem: MenuItem) => void
}) => {
const t = useTranslations()
const inboxes = useInboxStore((s) => s.inboxes)
const {
inboxes,
error: inboxesError,
loading: loadingInboxes,
initialized: inboxesInitialized,
} = useInboxesState()
const whatsappTemplates = useFlowTemplate((s) => s.whatsappTemplates)
const whatsappFlows = useWhatsappFlow((s) => s.whatsappFlows)
const messengerTemplates = useFlowTemplate((s) => s.messengerTemplates)
const beforeStep = useWatch({ name: "beforeStep" })
const channel = beforeStep?.channel
const hasInboxDependentMenus =
nodeType === nodeTypeSchema.enum.sendMessage &&
(channel === channelTypes.enum.whatsapp ||
channel === channelTypes.enum.messenger ||
channel === channelTypes.enum.omnichannel)

const [nodeMenus, setNodeMenus] = useState<MenuItem[]>([])
const inboxesUnavailable =
!inboxesInitialized || loadingInboxes || Boolean(inboxesError)

useEffect(() => {
const nodeConfig = nodeType ? allNodesConfig[nodeType]?.(t) : null
if (nodeConfig) {
setNodeMenus(
nodeConfig.menus(t, {
inboxes,
templates: { waTemplates: whatsappTemplates, messengerTemplates },
templates: {
waTemplates: whatsappTemplates,
messengerTemplates,
},
flows: { waFlows: whatsappFlows },
beforeStep,
inboxesUnavailable,
}),
)
)
} else {
setNodeMenus([])
}
Expand All @@ -192,25 +211,31 @@ const NodeEditorMenu = memo(
whatsappFlows,
messengerTemplates,
beforeStep,
inboxesUnavailable,
])

return (
nodeMenus.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline">
<PlusIcon />
{t("actions.create")}
</Button>
}
/>
<>
{inboxesError && hasInboxDependentMenus && (
<ErrorAlert message={`${t("states.error")}: ${inboxesError}`} />
)}
{nodeMenus.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="outline">
<PlusIcon />
{t("actions.create")}
</Button>
}
/>

<DropdownMenuContent className="w-full">
<RecursiveDropdownMenu data={nodeMenus} onClick={onClick} />
</DropdownMenuContent>
</DropdownMenu>
)
<DropdownMenuContent className="w-full">
<RecursiveDropdownMenu data={nodeMenus} onClick={onClick} />
</DropdownMenuContent>
</DropdownMenu>
)}
</>
)
},
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,11 @@ const messengerTemplate = {
const buildMenuData = (
channel: MenuData["beforeStep"]["channel"],
inboxes = [waInbox, messengerInbox, telegramInbox],
inboxesUnavailable = false,
): MenuData =>
({
inboxes,
inboxesUnavailable,
templates: {
waTemplates: [waTemplate],
messengerTemplates: [messengerTemplate],
Expand All @@ -66,6 +68,38 @@ const childLabels = (item?: MenuItem): string[] =>
(item?.children ?? []).map((child) => child.label)

describe("sendMessageEditorMenus — template message consolidation", () => {
it.each([
channelTypes.enum.whatsapp,
channelTypes.enum.messenger,
channelTypes.enum.omnichannel,
])("keeps independent entries when %s inbox data is unavailable", (channel) => {
const items = sendMessageEditorMenus(
t,
buildMenuData(channel, [waInbox, messengerInbox], true),
)
const labels = items.map((item) => item.label)

expect(labels).not.toContain(TEMPLATE_LABEL)
expect(labels).not.toContain("flows.actions.whatsappFlow")
expect(labels).toContain("flows.actions.sendText")
expect(labels).toContain("flows.actions.sendFile")
expect(labels).toContain("flows.actions.actions")
})

it.each([channelTypes.enum.whatsapp, channelTypes.enum.omnichannel])(
"keeps WhatsApp option list when %s inbox data is unavailable",
(channel) => {
const items = sendMessageEditorMenus(
t,
buildMenuData(channel, [waInbox, messengerInbox], true),
)

expect(items.map((item) => item.label)).toContain(
"flows.actions.whatsappOptionList",
)
},
)

it("shows exactly ONE Template Message item on omnichannel (no duplicate)", () => {
const items = sendMessageEditorMenus(
t,
Expand Down Expand Up @@ -121,6 +155,22 @@ describe("sendMessageEditorMenus — template message consolidation", () => {
])
})

it("keeps independent TikTok entries when inbox data is unavailable", () => {
const items = sendMessageEditorMenus(
t,
buildMenuData(channelTypes.enum.tiktok, [], true),
)

expect(items.map((item) => item.label)).toEqual([
"flows.actions.sendText",
"flows.actions.sendImage",
"flows.actions.sendMultipleImages",
"flows.actions.getUserData",
"flows.actions.typing",
"flows.actions.actions",
])
})

it("shows only Messenger inboxes when channel is messenger", () => {
const items = sendMessageEditorMenus(
t,
Expand Down Expand Up @@ -170,4 +220,15 @@ describe("integrationMenus", () => {
expect(menus[0]?.label).toBe(NO_TEMPLATES_LABEL)
expect(menus[0]?.stepType).toBeNull()
})

it("preserves template parent and empty state after successful empty inbox fetch", () => {
const items = sendMessageEditorMenus(
t,
buildMenuData(channelTypes.enum.whatsapp, [], false),
)
const templateItem = findTemplateItem(items)

expect(templateItem).toBeDefined()
expect(childLabels(templateItem)).toEqual([NO_TEMPLATES_LABEL])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,9 @@ const MENU_ORDER_BY_CHANNEL: Record<string, readonly string[]> = {
[channelTypes.enum.tiktok]: TIKTOK_MENU_ORDER,
}

const isInboxDependentMenuKey = (key: string) =>
key === "sendTemplateMessage" || key === "whatsappFlow"

export const sendMessageEditorMenus = (
t: TranslationFn,
menuData?: MenuData,
Expand All @@ -188,15 +191,25 @@ export const sendMessageEditorMenus = (
const allMenuItems = ALL_MENU_ITEMS(t, menuData)

if (channel === channelTypes.enum.omnichannel) {
return Object.values(allMenuItems)
return Object.entries(allMenuItems)
.filter(
([key]) =>
!(menuData?.inboxesUnavailable && isInboxDependentMenuKey(key)),
)
.map(([, item]) => item)
}

const menuOrder =
channel && MENU_ORDER_BY_CHANNEL[channel]
? MENU_ORDER_BY_CHANNEL[channel]
: BASE_MENU_ORDER

return menuOrder.map((key) => allMenuItems[key])
return menuOrder
.filter(
(key) =>
!(menuData?.inboxesUnavailable && isInboxDependentMenuKey(key)),
)
.map((key) => allMenuItems[key])
}

export const sendMessageEditorMenusWithButton = (
Expand Down
1 change: 1 addition & 0 deletions apps/builder/src/features/flows/react-flow/nodes/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export type FlowMenuData = {

export type MenuData = {
inboxes: ListInboxesResponse["data"]
inboxesUnavailable?: boolean
templates: FlowTemplateMenuData
flows: FlowMenuData
beforeStep: ChooseChannelStepSchema
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ beforeEach(() => {
})

describe("getAllInboxes", () => {
test("keeps successful empty fetch distinct from a failed fetch", async () => {
mocks.listInboxesAuthenticatedAPI.mockResolvedValueOnce({ data: [] })

const store = createInboxStore({ workspaceId: "workspace-1" })

await store.getState().initialize()

expect(store.getState()).toMatchObject({
inboxes: [],
error: null,
loadingInboxes: false,
initialized: true,
})
})

test("fetches inboxes for the workspace with includes and maxPerPage", async () => {
mocks.listInboxesAuthenticatedAPI.mockResolvedValueOnce({
data: [{ id: "inbox-1", name: "Support" }],
Expand Down
7 changes: 7 additions & 0 deletions apps/builder/src/features/inboxes/provider/inbox-hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ import type { SelectOption } from "@chatbotx.io/ui/components/form/select-field"
import { useEffect, useMemo, useState } from "react"
import { useInboxStore } from "./inbox-store-context"

export const useInboxesState = () => ({
inboxes: useInboxStore((state) => state.inboxes),
error: useInboxStore((state) => state.error),
loading: useInboxStore((state) => state.loadingInboxes),
initialized: useInboxStore((state) => state.initialized),
})

export const allInboxConfigs = {
omnichannel: {
label: "Omnichannel",
Expand Down