From 6c7893a5781a0b1c1445ab740ffd7834a91c8885 Mon Sep 17 00:00:00 2001 From: baokimho Date: Thu, 17 Sep 2026 03:06:49 +0300 Subject: [PATCH 1/3] fix(inboxes): distinguish fetch errors from empty state --- .../flows/react-flow/nodes/editor.tsx | 60 ++++++++++++------- .../provider/__tests__/inbox-store.test.ts | 15 +++++ .../features/inboxes/provider/inbox-hook.ts | 7 +++ 3 files changed, 59 insertions(+), 23 deletions(-) diff --git a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx index c56a7cb1f3..595f23af63 100644 --- a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx +++ b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx @@ -38,7 +38,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" @@ -162,7 +162,11 @@ const NodeEditorMenu = memo( onClick: (menuItem: MenuItem) => void }) => { const t = useTranslations() - const inboxes = useInboxStore((s) => s.inboxes) + const { + inboxes, + error: inboxesError, + loading: loadingInboxes, + } = useInboxesState() const whatsappTemplates = useFlowTemplate((s) => s.whatsappTemplates) const whatsappFlows = useWhatsappFlow((s) => s.whatsappFlows) const messengerTemplates = useFlowTemplate((s) => s.messengerTemplates) @@ -174,12 +178,17 @@ const NodeEditorMenu = memo( const nodeConfig = nodeType ? allNodesConfig[nodeType]?.(t) : null if (nodeConfig) { setNodeMenus( - nodeConfig.menus(t, { - inboxes, - templates: { waTemplates: whatsappTemplates, messengerTemplates }, - flows: { waFlows: whatsappFlows }, - beforeStep, - }), + loadingInboxes || inboxesError + ? [] + : nodeConfig.menus(t, { + inboxes, + templates: { + waTemplates: whatsappTemplates, + messengerTemplates, + }, + flows: { waFlows: whatsappFlows }, + beforeStep, + }), ) } else { setNodeMenus([]) @@ -192,25 +201,30 @@ const NodeEditorMenu = memo( whatsappFlows, messengerTemplates, beforeStep, + inboxesError, + loadingInboxes, ]) return ( - nodeMenus.length > 0 && ( - - - - {t("actions.create")} - - } - /> + <> + {inboxesError && } + {nodeMenus.length > 0 && ( + + + + {t("actions.create")} + + } + /> - - - - - ) + + + + + )} + ) }, ) diff --git a/apps/builder/src/features/inboxes/provider/__tests__/inbox-store.test.ts b/apps/builder/src/features/inboxes/provider/__tests__/inbox-store.test.ts index 32924654b0..198a90e198 100644 --- a/apps/builder/src/features/inboxes/provider/__tests__/inbox-store.test.ts +++ b/apps/builder/src/features/inboxes/provider/__tests__/inbox-store.test.ts @@ -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" }], diff --git a/apps/builder/src/features/inboxes/provider/inbox-hook.ts b/apps/builder/src/features/inboxes/provider/inbox-hook.ts index 56e28cd005..5998141495 100644 --- a/apps/builder/src/features/inboxes/provider/inbox-hook.ts +++ b/apps/builder/src/features/inboxes/provider/inbox-hook.ts @@ -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", From 90b01b313345685154715587bd67f9fe7dc7073a Mon Sep 17 00:00:00 2001 From: baokimho Date: Thu, 17 Sep 2026 15:27:32 +0300 Subject: [PATCH 2/3] fix(flows): keep inbox-independent menu actions available --- .../flows/react-flow/nodes/editor.tsx | 41 ++++++++----- .../nodes/send-message/__tests__/menu.test.ts | 61 +++++++++++++++++++ .../react-flow/nodes/send-message/menu.tsx | 17 +++++- .../features/flows/react-flow/nodes/types.ts | 1 + 4 files changed, 103 insertions(+), 17 deletions(-) diff --git a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx index 595f23af63..53233870cf 100644 --- a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx +++ b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx @@ -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 { @@ -166,30 +168,38 @@ const NodeEditorMenu = memo( 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([]) + const inboxesUnavailable = + !inboxesInitialized || loadingInboxes || Boolean(inboxesError) useEffect(() => { const nodeConfig = nodeType ? allNodesConfig[nodeType]?.(t) : null if (nodeConfig) { setNodeMenus( - loadingInboxes || inboxesError - ? [] - : nodeConfig.menus(t, { - inboxes, - templates: { - waTemplates: whatsappTemplates, - messengerTemplates, - }, - flows: { waFlows: whatsappFlows }, - beforeStep, - }), - ) + nodeConfig.menus(t, { + inboxes, + templates: { + waTemplates: whatsappTemplates, + messengerTemplates, + }, + flows: { waFlows: whatsappFlows }, + beforeStep, + inboxesUnavailable, + }), + ) } else { setNodeMenus([]) } @@ -201,13 +211,14 @@ const NodeEditorMenu = memo( whatsappFlows, messengerTemplates, beforeStep, - inboxesError, - loadingInboxes, + inboxesUnavailable, ]) return ( <> - {inboxesError && } + {inboxesError && hasInboxDependentMenus && ( + + )} {nodeMenus.length > 0 && ( ({ inboxes, + inboxesUnavailable, templates: { waTemplates: [waTemplate], messengerTemplates: [messengerTemplate], @@ -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, @@ -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, @@ -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]) + }) }) diff --git a/apps/builder/src/features/flows/react-flow/nodes/send-message/menu.tsx b/apps/builder/src/features/flows/react-flow/nodes/send-message/menu.tsx index 26226c1b1b..6fa8928345 100644 --- a/apps/builder/src/features/flows/react-flow/nodes/send-message/menu.tsx +++ b/apps/builder/src/features/flows/react-flow/nodes/send-message/menu.tsx @@ -180,6 +180,9 @@ const MENU_ORDER_BY_CHANNEL: Record = { [channelTypes.enum.tiktok]: TIKTOK_MENU_ORDER, } +const isInboxDependentMenuKey = (key: string) => + key === "sendTemplateMessage" || key === "whatsappFlow" + export const sendMessageEditorMenus = ( t: TranslationFn, menuData?: MenuData, @@ -188,7 +191,12 @@ 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 = @@ -196,7 +204,12 @@ export const sendMessageEditorMenus = ( ? 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 = ( diff --git a/apps/builder/src/features/flows/react-flow/nodes/types.ts b/apps/builder/src/features/flows/react-flow/nodes/types.ts index a34f70a8c0..c2bfd154db 100644 --- a/apps/builder/src/features/flows/react-flow/nodes/types.ts +++ b/apps/builder/src/features/flows/react-flow/nodes/types.ts @@ -31,6 +31,7 @@ export type FlowMenuData = { export type MenuData = { inboxes: ListInboxesResponse["data"] + inboxesUnavailable?: boolean templates: FlowTemplateMenuData flows: FlowMenuData beforeStep: ChooseChannelStepSchema From 35c4602edf5aa31434da52556bd1d8b2805fced1 Mon Sep 17 00:00:00 2001 From: The Bao Kim Ho Date: Fri, 18 Sep 2026 02:29:03 +0300 Subject: [PATCH 3/3] Improve error message formatting in ErrorAlert Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/builder/src/features/flows/react-flow/nodes/editor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx index 53233870cf..3443e3d77d 100644 --- a/apps/builder/src/features/flows/react-flow/nodes/editor.tsx +++ b/apps/builder/src/features/flows/react-flow/nodes/editor.tsx @@ -217,7 +217,7 @@ const NodeEditorMenu = memo( return ( <> {inboxesError && hasInboxDependentMenus && ( - + )} {nodeMenus.length > 0 && (