Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e729684
feat(builder): declare explicit viewport with safe-area support
eduardocodes Aug 18, 2026
e2bedb0
feat(vitest-config): add a live matchMedia polyfill for jsdom suites
eduardocodes Aug 18, 2026
fc479b5
test(ui): cover the useIsMobile breakpoint boundary
eduardocodes Aug 18, 2026
871efba
feat(ui): add a hamburger sidebar trigger for the mobile sheet
eduardocodes Aug 18, 2026
755dff7
feat(builder): add mobile header with sidebar trigger to workspace shell
eduardocodes Aug 18, 2026
51c9fb4
fix(builder): close mobile sidebar sheet when a nav link is tapped
eduardocodes Aug 18, 2026
3b3a398
fix(builder): make app tab strip horizontally scrollable on narrow vi…
eduardocodes Aug 18, 2026
1830f30
refactor(builder): replace inbox negative-margin hack with FullBleed …
eduardocodes Aug 18, 2026
47b4554
feat(builder): add mobile header to the manage console shell
eduardocodes Aug 18, 2026
9fc2820
feat(ui): scroll DataTable by default and add an opt-in mobile card view
eduardocodes Aug 18, 2026
9e3217d
feat(ui): add a generic mobile row card for data tables
eduardocodes Aug 18, 2026
90755df
fix(ui): stack data table toolbar filters on narrow viewports
eduardocodes Aug 18, 2026
0624ba7
fix(ui): keep a viewport gutter on dialogs that override max-width
eduardocodes Aug 18, 2026
260d87e
feat(builder): render contacts, flows and broadcasts tables as cards …
eduardocodes Aug 18, 2026
2c2c3d0
fix(analytics): collapse dashboard grid and nav to a single column on…
eduardocodes Aug 18, 2026
feab70c
fix(builder): stack setting rows into a single column on mobile
eduardocodes Aug 18, 2026
90b7435
feat(ui): expose useIsMobileState for layouts that must not guess
eduardocodes Aug 18, 2026
8818fc0
feat(vitest-config): stub ResizeObserver for jsdom suites
eduardocodes Aug 18, 2026
c863245
feat(builder): add mobile back and contact controls to the message he…
eduardocodes Aug 18, 2026
5515508
refactor(builder): extract inbox panes from the chat layout
eduardocodes Aug 18, 2026
2054f2f
feat(builder): render the inbox as a single-pane master detail view o…
eduardocodes Aug 18, 2026
eb03dfa
fix(builder): size inbox media, composer and popovers to the viewport
eduardocodes Aug 18, 2026
59d8a3e
fix(analytics): stop the admins card spanning a phantom second column…
eduardocodes Aug 18, 2026
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
131 changes: 131 additions & 0 deletions apps/builder/__tests__/app-tab.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import type { ComponentProps, ReactNode } from "react"
import { act } from "react"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"

vi.mock("next/link", () => ({
default: ({
children,
href,
...rest
}: {
children: ReactNode
href: string
className?: string
}) => (
<a href={href} {...rest}>
{children}
</a>
),
}))

const { AppTab } = await import("@/components/app-tab")

type Tab = ComponentProps<typeof AppTab>["tabs"][number]

const TABS: Tab[] = [
{ label: "General", href: "/settings/general", isActive: true },
{ label: "Channels", href: "/settings/channels", isActive: false },
{ label: "Integrations", href: "/settings/integrations", isActive: false },
{ label: "Admins", href: "/settings/admins", isActive: false },
{ label: "Inbox teams", href: "/settings/inbox-teams", isActive: false },
]

describe("AppTab", () => {
let container: HTMLDivElement
let root: Root

const render = (tabs: Tab[]) => {
act(() => {
root.render(<AppTab tabs={tabs} />)
})
}

const strip = () => {
const anchor = container.querySelector("a")
return anchor?.parentElement ?? null
}

beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
container = document.createElement("div")
document.body.append(container)
root = createRoot(container)
})

afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
})

test("renders every tab", () => {
render(TABS)

const labels = Array.from(container.querySelectorAll("a")).map(
(anchor) => anchor.textContent,
)
expect(labels).toEqual([
"General",
"Channels",
"Integrations",
"Admins",
"Inbox teams",
])
})

test("scrolls the strip instead of overflowing the page", () => {
render(TABS)

const className = strip()?.className ?? ""
expect(className).toContain("overflow-x-auto")
expect(className).toContain("flex-nowrap")
})

test("keeps each tab at its natural width so labels never squeeze", () => {
render(TABS)

for (const anchor of Array.from(container.querySelectorAll("a"))) {
expect(anchor.className).toContain("shrink-0")
expect(anchor.className).toContain("whitespace-nowrap")
}
})

test("tightens padding on small screens and restores it from md up", () => {
render(TABS)

const className = strip()?.className ?? ""
expect(className).toContain("px-4")
expect(className).toContain("md:px-8")
expect(className).toContain("gap-4")
expect(className).toContain("md:gap-8")
})

test("marks the active tab", () => {
render(TABS)

const active = Array.from(container.querySelectorAll("a")).find((anchor) =>
anchor.className.includes("border-neutral-700"),
)
expect(active?.textContent).toBe("General")
})

test("renders a disabled tab as a non-link", () => {
render([
{ label: "General", href: "/settings/general", isActive: true },
{
label: "Locked",
href: "/settings/locked",
isActive: false,
disabled: true,
},
])

const anchors = Array.from(container.querySelectorAll("a")).map(
(anchor) => anchor.textContent,
)
expect(anchors).toEqual(["General"])
expect(container.textContent).toContain("Locked")
})
})
158 changes: 158 additions & 0 deletions apps/builder/__tests__/chat-layout-mobile.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { setViewportWidth } from "@chatbotx.io/vitest-config/setup-dom"
import { act } from "react"
import { createRoot, type Root } from "react-dom/client"
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"

vi.mock("next-intl", () => ({
useTranslations: () => (key: string) => key,
}))

vi.mock("@/features/chat/chat-realtime", () => ({
ChatRealtime: () => <div data-testid="realtime" />,
}))

vi.mock("@/features/chat/chat-panes", () => ({
ConversationListPane: () => <div data-testid="list-pane" />,
MessageThreadPane: ({
onBack,
onOpenContact,
}: {
onBack?: () => void
onOpenContact?: () => void
}) => (
<div data-testid="thread-pane">
{onBack && (
<button data-testid="back" onClick={onBack} type="button">
back
</button>
)}
{onOpenContact && (
<button
data-testid="open-contact"
onClick={onOpenContact}
type="button"
>
contact
</button>
)}
</div>
),
ContactDetailPane: () => <div data-testid="contact-pane" />,
}))

const storeState = {
conversations: [] as unknown[],
isFirstLoadConversation: false,
isLoadingConversation: false,
isBootstrappingUrlConversation: false,
activeConversationId: null as string | null,
setActiveConversationId: vi.fn((id: string | null) => {
storeState.activeConversationId = id
}),
}

vi.mock("@/features/chat/store/chat-store-provider", () => ({
useChatStore: (selector: (state: typeof storeState) => unknown) =>
selector(storeState),
}))

const { ChatLayout } = await import("@/features/chat/chat-layout")

describe("ChatLayout", () => {
let container: HTMLDivElement
let root: Root

const render = () => {
act(() => {
root.render(<ChatLayout workspaceId="w1" />)
})
}

const find = (id: string) =>
container.querySelector<HTMLElement>(`[data-testid="${id}"]`)

beforeEach(() => {
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
storeState.activeConversationId = null
storeState.setActiveConversationId.mockClear()
container = document.createElement("div")
document.body.append(container)
root = createRoot(container)
})

afterEach(() => {
act(() => {
root.unmount()
})
container.remove()
setViewportWidth(1024)
})

test("shows only the conversation list on mobile with nothing selected", () => {
setViewportWidth(375)
render()

expect(find("list-pane")).not.toBeNull()
expect(find("thread-pane")).toBeNull()
// The three-column group must not mount on a phone.
expect(container.querySelector("[data-panel-group]")).toBeNull()
})

test("shows the thread with a back control once a conversation is active", () => {
storeState.activeConversationId = "c1"
setViewportWidth(375)
render()

expect(find("thread-pane")).not.toBeNull()
expect(find("list-pane")).toBeNull()
expect(find("back")).not.toBeNull()
})

test("back clears the active conversation, returning to the list", () => {
storeState.activeConversationId = "c1"
setViewportWidth(375)
render()

act(() => {
find("back")?.dispatchEvent(
new MouseEvent("click", { bubbles: true, cancelable: true }),
)
})

expect(storeState.setActiveConversationId).toHaveBeenCalledWith(null)
})

test("offers the contact panel behind a control instead of a third column", () => {
storeState.activeConversationId = "c1"
setViewportWidth(375)
render()

expect(find("open-contact")).not.toBeNull()
// The sheet is closed until asked for, so the panel is not mounted yet.
expect(find("contact-pane")).toBeNull()
})

test("renders all three panes side by side from md up", () => {
storeState.activeConversationId = "c1"
setViewportWidth(1440)
render()

expect(find("list-pane")).not.toBeNull()
expect(find("thread-pane")).not.toBeNull()
expect(find("contact-pane")).not.toBeNull()
// No mobile-only affordances leak into the desktop layout.
expect(find("back")).toBeNull()
expect(find("open-contact")).toBeNull()
})

test("keeps the realtime socket mounted in every layout", () => {
setViewportWidth(375)
render()
expect(find("realtime")).not.toBeNull()

act(() => {
setViewportWidth(1440)
})
expect(find("realtime")).not.toBeNull()
})
})
Loading
Loading