mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
fix: non-interactive elements without roles (#5804)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
6
.cursor/rules/testing.mdc
Normal file
6
.cursor/rules/testing.mdc
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description: Whenever the user asks to write or update a test file for .tsx or .ts files.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
Use the rules in this file when writing tests [copilot-instructions.md](mdc:.github/copilot-instructions.md)
|
||||
@@ -11,7 +11,11 @@ vi.mock("lucide-react", () => ({
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children, onClick }) => (
|
||||
<button tabIndex={0} onClick={onClick} style={{ display: "inline-block" }}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
@@ -67,8 +71,10 @@ describe("SummaryMetadata", () => {
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
@@ -101,8 +107,10 @@ describe("SummaryMetadata", () => {
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
const btn = screen
|
||||
.getAllByRole("button")
|
||||
.find((el) => el.textContent?.includes("environments.surveys.summary.drop_offs"));
|
||||
if (!btn) throw new Error("DropOffs toggle button not found");
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -100,8 +100,8 @@ export const SummaryMetadata = ({
|
||||
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<TooltipTrigger onClick={() => setShowDropOffs(!showDropOffs)} data-testid="dropoffs-toggle">
|
||||
<div className="flex h-full cursor-pointer flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -117,15 +117,13 @@ export const SummaryMetadata = ({
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<button
|
||||
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -189,4 +189,30 @@ describe("ResponseNotes", () => {
|
||||
expect(updateFetchedResponses).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
test("pressing Enter in textarea only submits form and doesn't trigger parent button onClick", async () => {
|
||||
vi.mocked(createResponseNoteAction).mockResolvedValueOnce("createdNote" as any);
|
||||
render(
|
||||
<ResponseNotes
|
||||
user={dummyUser}
|
||||
responseId={dummyResponseId}
|
||||
notes={[]}
|
||||
isOpen={true}
|
||||
setIsOpen={setIsOpen}
|
||||
updateFetchedResponses={updateFetchedResponses}
|
||||
locale={dummyLocale}
|
||||
/>
|
||||
);
|
||||
const textarea = screen.getByRole("textbox");
|
||||
await userEvent.type(textarea, "New note");
|
||||
await userEvent.type(textarea, "{enter}");
|
||||
await waitFor(() => {
|
||||
expect(createResponseNoteAction).toHaveBeenCalledWith({
|
||||
responseId: dummyResponseId,
|
||||
text: "New note",
|
||||
});
|
||||
expect(updateFetchedResponses).toHaveBeenCalled();
|
||||
expect(setIsOpen).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,49 +98,56 @@ export const ResponseNotes = ({
|
||||
const unresolvedNotes = useMemo(() => notes.filter((note) => !note.isResolved), [notes]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
!isOpen && unresolvedNotes.length && "group/hint cursor-pointer bg-white hover:-right-3",
|
||||
!isOpen && !unresolvedNotes.length && "cursor-pointer bg-slate-50",
|
||||
isOpen
|
||||
? "-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
: unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isOpen) setIsOpen(true);
|
||||
}}>
|
||||
<>
|
||||
{!isOpen ? (
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">{t("common.note")}</h3>
|
||||
<button
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
unresolvedNotes.length
|
||||
? "group/hint cursor-pointer bg-white hover:-right-3"
|
||||
: "cursor-pointer bg-slate-50",
|
||||
unresolvedNotes.length
|
||||
? "right-0 top-[8.33%] h-5/6 max-h-[600px] w-1/12"
|
||||
: "right-[120px] top-[8.333%] h-5/6 max-h-[600px] w-1/12 group-hover:right-[0]"
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
aria-label="Open notes"
|
||||
type="button"
|
||||
tabIndex={0}
|
||||
style={{ outline: "none" }}>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"space-y-2 rounded-t-lg px-2 pb-2 pt-2",
|
||||
unresolvedNotes.length ? "flex h-12 items-center justify-end bg-amber-50" : "bg-slate-200"
|
||||
)}>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="group flex items-center">
|
||||
<h3 className="float-left ml-4 pb-1 text-sm text-slate-600">{t("common.note")}</h3>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
) : (
|
||||
<div className="float-left mr-1.5">
|
||||
<Maximize2Icon className="h-4 w-4 text-amber-500 hover:text-amber-600 group-hover/hint:scale-110" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!unresolvedNotes.length ? (
|
||||
<div className="flex flex-1 items-center justify-end pr-3">
|
||||
<span>
|
||||
<PlusIcon className="h-5 w-5 text-slate-400" />
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative flex h-full flex-col">
|
||||
<div
|
||||
className={clsx(
|
||||
"absolute w-1/4 rounded-lg border border-slate-200 shadow-sm transition-all",
|
||||
"-right-2 top-0 h-5/6 max-h-[600px] w-1/4 bg-white"
|
||||
)}>
|
||||
<div className="rounded-t-lg bg-amber-50 px-4 pb-3 pt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="group flex items-center">
|
||||
@@ -254,6 +261,6 @@ export const ResponseNotes = ({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
174
apps/web/modules/ee/billing/components/pricing-table.test.tsx
Normal file
174
apps/web/modules/ee/billing/components/pricing-table.test.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TOrganizationBillingPeriod } from "@formbricks/types/organizations";
|
||||
import { PricingTable } from "./pricing-table";
|
||||
|
||||
// Mock the env module
|
||||
vi.mock("@/lib/env", () => ({
|
||||
env: {
|
||||
IS_FORMBRICKS_CLOUD: "0",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the useRouter hook
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the actions module
|
||||
vi.mock("@/modules/ee/billing/actions", () => {
|
||||
const mockDate = new Date("2024-03-15T00:00:00.000Z");
|
||||
return {
|
||||
isSubscriptionCancelledAction: vi.fn(() => Promise.resolve({ data: { date: mockDate } })),
|
||||
manageSubscriptionAction: vi.fn(() => Promise.resolve({ data: null })),
|
||||
upgradePlanAction: vi.fn(() => Promise.resolve({ data: null })),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the useTranslate hook
|
||||
vi.mock("@tolgee/react", () => ({
|
||||
useTranslate: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("PricingTable", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("should display a 'Cancelling' badge with the correct date if the subscription is being cancelled", async () => {
|
||||
const mockOrganization = {
|
||||
id: "org-123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: "free",
|
||||
period: "yearly",
|
||||
periodStart: new Date(),
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: 100,
|
||||
},
|
||||
projects: 1,
|
||||
},
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockStripePriceLookupKeys = {
|
||||
STARTUP_MONTHLY: "startup_monthly",
|
||||
STARTUP_YEARLY: "startup_yearly",
|
||||
SCALE_MONTHLY: "scale_monthly",
|
||||
SCALE_YEARLY: "scale_yearly",
|
||||
};
|
||||
|
||||
const mockProjectFeatureKeys = {
|
||||
FREE: "free",
|
||||
STARTUP: "startup",
|
||||
SCALE: "scale",
|
||||
ENTERPRISE: "enterprise",
|
||||
};
|
||||
|
||||
render(
|
||||
<PricingTable
|
||||
organization={mockOrganization as any}
|
||||
environmentId="env-123"
|
||||
peopleCount={50}
|
||||
responseCount={75}
|
||||
projectCount={1}
|
||||
stripePriceLookupKeys={mockStripePriceLookupKeys}
|
||||
projectFeatureKeys={mockProjectFeatureKeys}
|
||||
hasBillingRights={true}
|
||||
/>
|
||||
);
|
||||
|
||||
const expectedDate = new Date("2024-03-15T00:00:00.000Z").toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const cancellingBadge = await screen.findByText(`Cancelling: ${expectedDate}`);
|
||||
expect(cancellingBadge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("billing period toggle buttons have correct aria-pressed attributes", async () => {
|
||||
const MockPricingTable = () => {
|
||||
const [planPeriod, setPlanPeriod] = useState<TOrganizationBillingPeriod>("yearly");
|
||||
|
||||
const mockOrganization = {
|
||||
id: "org-123",
|
||||
name: "Test Organization",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
billing: {
|
||||
plan: "free",
|
||||
period: "yearly",
|
||||
periodStart: new Date(),
|
||||
stripeCustomerId: null,
|
||||
limits: {
|
||||
monthly: {
|
||||
responses: 100,
|
||||
miu: 100,
|
||||
},
|
||||
projects: 1,
|
||||
},
|
||||
},
|
||||
isAIEnabled: false,
|
||||
};
|
||||
|
||||
const mockStripePriceLookupKeys = {
|
||||
STARTUP_MONTHLY: "startup_monthly",
|
||||
STARTUP_YEARLY: "startup_yearly",
|
||||
SCALE_MONTHLY: "scale_monthly",
|
||||
SCALE_YEARLY: "scale_yearly",
|
||||
};
|
||||
|
||||
const mockProjectFeatureKeys = {
|
||||
FREE: "free",
|
||||
STARTUP: "startup",
|
||||
SCALE: "scale",
|
||||
ENTERPRISE: "enterprise",
|
||||
};
|
||||
|
||||
const handleMonthlyToggle = (period: TOrganizationBillingPeriod) => {
|
||||
setPlanPeriod(period);
|
||||
};
|
||||
|
||||
return (
|
||||
<PricingTable
|
||||
organization={mockOrganization as any}
|
||||
environmentId="env-123"
|
||||
peopleCount={50}
|
||||
responseCount={75}
|
||||
projectCount={1}
|
||||
stripePriceLookupKeys={mockStripePriceLookupKeys}
|
||||
projectFeatureKeys={mockProjectFeatureKeys}
|
||||
hasBillingRights={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
render(<MockPricingTable />);
|
||||
|
||||
const monthlyButton = screen.getByText("environments.settings.billing.monthly");
|
||||
const yearlyButton = screen.getByText("environments.settings.billing.annually");
|
||||
|
||||
expect(yearlyButton).toHaveAttribute("aria-pressed", "true");
|
||||
expect(monthlyButton).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(monthlyButton);
|
||||
|
||||
expect(yearlyButton).toHaveAttribute("aria-pressed", "false");
|
||||
expect(monthlyButton).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
});
|
||||
@@ -154,7 +154,17 @@ export const PricingTable = ({
|
||||
className="mx-2"
|
||||
size="normal"
|
||||
type="warning"
|
||||
text={`Cancelling: ${cancellingOn ? cancellingOn.toDateString() : ""}`}
|
||||
text={`Cancelling: ${
|
||||
cancellingOn
|
||||
? cancellingOn.toLocaleDateString("en-US", {
|
||||
weekday: "short",
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
@@ -252,14 +262,16 @@ export const PricingTable = ({
|
||||
<div className="mx-auto mb-12">
|
||||
<div className="gap-x-2">
|
||||
<div className="mb-4 flex w-fit cursor-pointer overflow-hidden rounded-lg border border-slate-200 p-1 lg:mb-0">
|
||||
<div
|
||||
<button
|
||||
aria-pressed={planPeriod === "monthly"}
|
||||
className={`flex-1 rounded-md px-4 py-0.5 text-center ${
|
||||
planPeriod === "monthly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
onClick={() => handleMonthlyToggle("monthly")}>
|
||||
{t("environments.settings.billing.monthly")}
|
||||
</div>
|
||||
<div
|
||||
</button>
|
||||
<button
|
||||
aria-pressed={planPeriod === "yearly"}
|
||||
className={`flex-1 items-center whitespace-nowrap rounded-md py-0.5 pl-4 pr-2 text-center ${
|
||||
planPeriod === "yearly" ? "bg-slate-200 font-semibold" : "bg-transparent"
|
||||
}`}
|
||||
@@ -268,7 +280,7 @@ export const PricingTable = ({
|
||||
<span className="ml-2 inline-flex items-center rounded-full border border-green-200 bg-green-100 px-2.5 py-0.5 text-xs font-medium text-green-800">
|
||||
{t("environments.settings.billing.get_2_months_free")} 🔥
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mx-auto grid max-w-md grid-cols-1 gap-y-8 lg:mx-0 lg:-mb-14 lg:max-w-none lg:grid-cols-4">
|
||||
<div
|
||||
|
||||
@@ -297,7 +297,7 @@ export const UploadContactsCSVButton = ({
|
||||
<div className="sticky top-0 flex h-full flex-col rounded-lg">
|
||||
<button
|
||||
className={cn(
|
||||
"absolute top-0 right-0 hidden pt-4 pr-4 text-slate-400 hover:text-slate-500 focus:ring-0 focus:outline-none sm:block"
|
||||
"absolute right-0 top-0 hidden pr-4 pt-4 text-slate-400 hover:text-slate-500 focus:outline-none focus:ring-0 sm:block"
|
||||
)}
|
||||
onClick={() => {
|
||||
resetState(true);
|
||||
@@ -343,7 +343,7 @@ export const UploadContactsCSVButton = ({
|
||||
)}
|
||||
onDragOver={(e) => handleDragOver(e)}
|
||||
onDrop={(e) => handleDrop(e)}>
|
||||
<div className="flex flex-col items-center justify-center pt-5 pb-6">
|
||||
<div className="flex flex-col items-center justify-center pb-6 pt-5">
|
||||
<ArrowUpFromLineIcon className="h-6 text-slate-500" />
|
||||
<p className={cn("mt-2 text-center text-sm text-slate-500")}>
|
||||
<span className="font-semibold">{t("common.upload_input_description")}</span>
|
||||
|
||||
@@ -8,8 +8,28 @@ import { TSegment } from "@formbricks/types/segment";
|
||||
|
||||
// Mock the Modal component
|
||||
vi.mock("@/modules/ui/components/modal", () => ({
|
||||
Modal: ({ children, open }: { children: React.ReactNode; open: boolean }) => {
|
||||
return open ? <div>{children}</div> : null; // NOSONAR // This is a mock
|
||||
Modal: ({
|
||||
children,
|
||||
open,
|
||||
closeOnOutsideClick,
|
||||
setOpen,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
closeOnOutsideClick?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
}) => {
|
||||
return open ? ( // NOSONAR // This is a mock
|
||||
<button
|
||||
data-testid="modal-overlay"
|
||||
onClick={(e) => {
|
||||
if (closeOnOutsideClick && e.target === e.currentTarget && setOpen) {
|
||||
setOpen(false);
|
||||
}
|
||||
}}>
|
||||
<div data-testid="modal-content">{children}</div>
|
||||
</button>
|
||||
) : null; // NOSONAR // This is a mock
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -280,7 +300,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Person (userId) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("userId"),
|
||||
() => screen.getByTestId("filter-btn-person-userId"),
|
||||
"person",
|
||||
{ personIdentifier: "userId" },
|
||||
"equals",
|
||||
@@ -290,7 +310,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Email Address) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Email Address"),
|
||||
() => screen.getByTestId("filter-btn-attribute-email"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "email" },
|
||||
"equals",
|
||||
@@ -300,7 +320,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Plan Type) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Plan Type"),
|
||||
() => screen.getByTestId("filter-btn-attribute-plan"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "plan" },
|
||||
"equals",
|
||||
@@ -310,7 +330,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Active Users) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Active Users"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg1"),
|
||||
"segment",
|
||||
{ segmentId: "seg1" },
|
||||
"userIsIn",
|
||||
@@ -320,7 +340,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Paying Customers) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Paying Customers"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg2"),
|
||||
"segment",
|
||||
{ segmentId: "seg2" },
|
||||
"userIsIn",
|
||||
@@ -330,7 +350,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Phone) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.phone"),
|
||||
() => screen.getByTestId("filter-btn-device-phone"),
|
||||
"device",
|
||||
{ deviceType: "phone" },
|
||||
"equals",
|
||||
@@ -340,7 +360,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Desktop) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.desktop"),
|
||||
() => screen.getByTestId("filter-btn-device-desktop"),
|
||||
"device",
|
||||
{ deviceType: "desktop" },
|
||||
"equals",
|
||||
@@ -366,7 +386,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Person (userId) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByTestId("person-filter-item"), // Use testid from component
|
||||
() => screen.getByTestId("filter-btn-person-userId"),
|
||||
"person",
|
||||
{ personIdentifier: "userId" },
|
||||
"equals",
|
||||
@@ -376,7 +396,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Email Address) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Email Address"),
|
||||
() => screen.getByTestId("filter-btn-attribute-email"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "email" },
|
||||
"equals",
|
||||
@@ -386,7 +406,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Attribute (Plan Type) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Plan Type"),
|
||||
() => screen.getByTestId("filter-btn-attribute-plan"),
|
||||
"attribute",
|
||||
{ contactAttributeKey: "plan" },
|
||||
"equals",
|
||||
@@ -412,7 +432,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Active Users) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Active Users"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg1"),
|
||||
"segment",
|
||||
{ segmentId: "seg1" },
|
||||
"userIsIn",
|
||||
@@ -422,7 +442,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Segment (Paying Customers) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("Paying Customers"),
|
||||
() => screen.getByTestId("filter-btn-segment-seg2"),
|
||||
"segment",
|
||||
{ segmentId: "seg2" },
|
||||
"userIsIn",
|
||||
@@ -448,7 +468,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Phone) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.phone"),
|
||||
() => screen.getByTestId("filter-btn-device-phone"),
|
||||
"device",
|
||||
{ deviceType: "phone" },
|
||||
"equals",
|
||||
@@ -458,7 +478,7 @@ describe("AddFilterModal", () => {
|
||||
|
||||
test("handles Device (Desktop) filter add (click/keydown)", async () => {
|
||||
await testFilterInteraction(
|
||||
() => screen.getByText("environments.segments.desktop"),
|
||||
() => screen.getByTestId("filter-btn-device-desktop"),
|
||||
"device",
|
||||
{ deviceType: "desktop" },
|
||||
"equals",
|
||||
@@ -510,4 +530,86 @@ describe("AddFilterModal", () => {
|
||||
await user.type(searchInput, "nonexistentfilter");
|
||||
expect(await screen.findByText("environments.segments.no_filters_yet")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("verifies keyboard navigation through filter buttons", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
|
||||
// Get the search input to start tabbing from
|
||||
const searchInput = screen.getByPlaceholderText("Browse filters...");
|
||||
searchInput.focus();
|
||||
|
||||
// Tab to the first tab button ("all")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/common\.all/);
|
||||
|
||||
// Tab to the second tab button ("attributes")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/person_and_attributes/);
|
||||
|
||||
// Tab to the third tab button ("segments")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/common\.segments/);
|
||||
|
||||
// Tab to the fourth tab button ("devices")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent(/environments\.segments\.devices/);
|
||||
|
||||
// Tab to the first filter button ("Email Address")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent("Email Address");
|
||||
|
||||
// Tab to the second filter button ("Plan Type")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent("Plan Type");
|
||||
|
||||
// Tab to the third filter button ("userId")
|
||||
await user.tab();
|
||||
expect(document.activeElement).toHaveTextContent("userId");
|
||||
});
|
||||
|
||||
test("button elements are accessible to screen readers", () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons.length).toBeGreaterThan(0); // Verify buttons exist
|
||||
|
||||
// Check that buttons are focusable (they should be by default)
|
||||
buttons.forEach((button) => {
|
||||
expect(button).not.toHaveAttribute("aria-hidden", "true");
|
||||
expect(button).not.toHaveAttribute("tabIndex", "-1"); // Should not be unfocusable
|
||||
});
|
||||
});
|
||||
|
||||
test("closes the modal when clicking outside the content area", async () => {
|
||||
render(
|
||||
<AddFilterModal
|
||||
open={true}
|
||||
setOpen={setOpen}
|
||||
onAddFilter={onAddFilter}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
segments={mockSegments}
|
||||
/>
|
||||
);
|
||||
|
||||
const modalOverlay = screen.getByTestId("modal-overlay");
|
||||
await user.click(modalOverlay);
|
||||
|
||||
expect(setOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import type {
|
||||
TSegmentAttributeFilter,
|
||||
TSegmentPersonFilter,
|
||||
} from "@formbricks/types/segment";
|
||||
import AttributeTabContent from "./attribute-tab-content";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
interface TAddFilterModalProps {
|
||||
open: boolean;
|
||||
@@ -26,7 +28,7 @@ interface TAddFilterModalProps {
|
||||
|
||||
type TFilterType = "attribute" | "segment" | "device" | "person";
|
||||
|
||||
const handleAddFilter = ({
|
||||
export const handleAddFilter = ({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
@@ -132,92 +134,8 @@ const handleAddFilter = ({
|
||||
}
|
||||
};
|
||||
|
||||
interface AttributeTabContentProps {
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function AttributeTabContent({ contactAttributeKeys, onAddFilter, setOpen }: AttributeTabContentProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.person")}</h2>
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
tabIndex={0}
|
||||
data-testid="person-filter-item"
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>{t("common.user_id")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-2" />
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.attributes")}</h2>
|
||||
</div>
|
||||
{contactAttributeKeys.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeKey.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeKey.name ?? attributeKey.key}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AddFilterModal({
|
||||
// NOSONAR // the read-only attribute doesn't work as expected yet
|
||||
onAddFilter,
|
||||
open,
|
||||
setOpen,
|
||||
@@ -315,161 +233,68 @@ export function AddFilterModal({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{allFiltersFiltered.map((filters, index) => {
|
||||
return (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={attributeKey.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<TagIcon className="h-4 w-4" />
|
||||
<p>{attributeKey.name ?? attributeKey.key}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={personAttribute.name}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<FingerprintIcon className="h-4 w-4" />
|
||||
<p>{personAttribute.name}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.segments.map((segment) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={segment.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{filters.devices.map((deviceType) => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
key={deviceType.id}
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
{allFiltersFiltered.map((filters, index) => (
|
||||
<div key={index}>
|
||||
{filters.attributes.map((attributeKey) => (
|
||||
<FilterButton
|
||||
key={attributeKey.id}
|
||||
data-testid={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
type: "attribute",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
contactAttributeKey: attributeKey.key,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MonitorSmartphoneIcon className="h-4 w-4" />
|
||||
<span>{deviceType.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
const getAttributesTabContent = () => {
|
||||
return (
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={contactAttributeKeysFiltered}
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
/>
|
||||
);
|
||||
};
|
||||
{filters.contactAttributeFiltered.map((personAttribute) => (
|
||||
<FilterButton
|
||||
key={personAttribute.name}
|
||||
data-testid={`filter-btn-person-${personAttribute.name}`}
|
||||
icon={<FingerprintIcon className="h-4 w-4" />}
|
||||
label={personAttribute.name}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "person",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
const getSegmentsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{segmentsFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_segments_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{segmentsFiltered
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => {
|
||||
return (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
{filters.segments.map((segment) => (
|
||||
<FilterButton
|
||||
key={segment.id}
|
||||
tabIndex={0}
|
||||
data-testid={`filter-btn-segment-${segment.id}`}
|
||||
icon={<Users2Icon className="h-4 w-4" />}
|
||||
label={segment.title}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
@@ -488,12 +313,91 @@ export function AddFilterModal({
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<Users2Icon className="h-4 w-4" />
|
||||
<p>{segment.title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{filters.devices.map((deviceType) => (
|
||||
<FilterButton
|
||||
key={deviceType.id}
|
||||
data-testid={`filter-btn-device-${deviceType.id}`}
|
||||
icon={<MonitorSmartphoneIcon className="h-4 w-4" />}
|
||||
label={deviceType.name}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const getAttributesTabContent = () => {
|
||||
return (
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={contactAttributeKeysFiltered}
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getSegmentsTabContent = () => {
|
||||
return (
|
||||
<>
|
||||
{segmentsFiltered.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_segments_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{segmentsFiltered
|
||||
.filter((segment) => !segment.isPrivate)
|
||||
.map((segment) => (
|
||||
<FilterButton
|
||||
key={segment.id}
|
||||
data-testid={`filter-btn-segment-${segment.id}`}
|
||||
icon={<Users2Icon className="h-4 w-4" />}
|
||||
label={segment.title}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type: "segment",
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
segmentId: segment.id,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -502,10 +406,11 @@ export function AddFilterModal({
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{deviceTypesFiltered.map((deviceType) => (
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50"
|
||||
<FilterButton
|
||||
key={deviceType.id}
|
||||
tabIndex={0}
|
||||
data-testid={`filter-btn-device-${deviceType.id}`}
|
||||
icon={<MonitorSmartphoneIcon className="h-4 w-4" />}
|
||||
label={deviceType.name}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type: "device",
|
||||
@@ -524,10 +429,8 @@ export function AddFilterModal({
|
||||
deviceType: deviceType.id,
|
||||
});
|
||||
}
|
||||
}}>
|
||||
<MonitorSmartphoneIcon className="h-4 w-4" />
|
||||
<span>{deviceType.name}</span>
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import AttributeTabContent from "./attribute-tab-content";
|
||||
|
||||
describe("AttributeTabContent", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockContactAttributeKeys: TContactAttributeKey[] = [
|
||||
{ id: "attr1", key: "email", name: "Email Address", environmentId: "env1" } as TContactAttributeKey,
|
||||
{ id: "attr2", key: "plan", name: "Plan Type", environmentId: "env1" } as TContactAttributeKey,
|
||||
];
|
||||
|
||||
test("renders person and attribute buttons", () => {
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("filter-btn-person-userId")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filter-btn-attribute-email")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("filter-btn-attribute-plan")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("shows empty state when no attributes", () => {
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={[]}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/no_attributes_yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls handleAddFilter with correct args for person", async () => {
|
||||
const handleAddFilter = vi.fn();
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("filter-btn-person-userId"));
|
||||
expect(handleAddFilter).toHaveBeenCalledWith(expect.objectContaining({ type: "person" }));
|
||||
});
|
||||
|
||||
test("calls handleAddFilter with correct args for attribute", async () => {
|
||||
const handleAddFilter = vi.fn();
|
||||
render(
|
||||
<AttributeTabContent
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
onAddFilter={vi.fn()}
|
||||
setOpen={vi.fn()}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("filter-btn-attribute-email"));
|
||||
expect(handleAddFilter).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ type: "attribute", contactAttributeKey: "email" })
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { FingerprintIcon, TagIcon } from "lucide-react";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
import type { TBaseFilter } from "@formbricks/types/segment";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
interface AttributeTabContentProps {
|
||||
contactAttributeKeys: TContactAttributeKey[];
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleAddFilter: (args: {
|
||||
type: "attribute" | "person";
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
// Helper component to render a FilterButton with common handlers
|
||||
function FilterButtonWithHandler({
|
||||
dataTestId,
|
||||
icon,
|
||||
label,
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
contactAttributeKey,
|
||||
}: {
|
||||
dataTestId: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
type: "attribute" | "person";
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
handleAddFilter: (args: {
|
||||
type: "attribute" | "person";
|
||||
onAddFilter: (filter: TBaseFilter) => void;
|
||||
setOpen: (open: boolean) => void;
|
||||
contactAttributeKey?: string;
|
||||
}) => void;
|
||||
contactAttributeKey?: string;
|
||||
}) {
|
||||
return (
|
||||
<FilterButton
|
||||
data-testid={dataTestId}
|
||||
icon={icon}
|
||||
label={label}
|
||||
onClick={() => {
|
||||
handleAddFilter({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
});
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleAddFilter({
|
||||
type,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
...(type === "attribute" ? { contactAttributeKey } : {}),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttributeTabContent({
|
||||
contactAttributeKeys,
|
||||
onAddFilter,
|
||||
setOpen,
|
||||
handleAddFilter,
|
||||
}: AttributeTabContentProps) {
|
||||
const { t } = useTranslate();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.person")}</h2>
|
||||
<div>
|
||||
<FilterButtonWithHandler
|
||||
dataTestId="filter-btn-person-userId"
|
||||
icon={<FingerprintIcon className="h-4 w-4" />}
|
||||
label={t("common.user_id")}
|
||||
type="person"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr className="my-2" />
|
||||
|
||||
<div>
|
||||
<h2 className="text-base font-medium">{t("common.attributes")}</h2>
|
||||
</div>
|
||||
{contactAttributeKeys.length === 0 && (
|
||||
<div className="flex w-full items-center justify-center gap-4 rounded-lg px-2 py-1 text-sm">
|
||||
<p>{t("environments.segments.no_attributes_yet")}</p>
|
||||
</div>
|
||||
)}
|
||||
{contactAttributeKeys.map((attributeKey) => (
|
||||
<FilterButtonWithHandler
|
||||
key={attributeKey.id}
|
||||
dataTestId={`filter-btn-attribute-${attributeKey.key}`}
|
||||
icon={<TagIcon className="h-4 w-4" />}
|
||||
label={attributeKey.name ?? attributeKey.key}
|
||||
type="attribute"
|
||||
onAddFilter={onAddFilter}
|
||||
setOpen={setOpen}
|
||||
handleAddFilter={handleAddFilter}
|
||||
contactAttributeKey={attributeKey.key}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AttributeTabContent;
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import FilterButton from "./filter-button";
|
||||
|
||||
describe("FilterButton", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders icon and label", () => {
|
||||
render(
|
||||
<FilterButton icon={<span data-testid="icon">icon</span>} label="Test Label" onClick={() => {}} />
|
||||
);
|
||||
expect(screen.getByTestId("icon")).toBeInTheDocument();
|
||||
expect(screen.getByText("Test Label")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls onClick when clicked", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<FilterButton icon={<span />} label="Click Me" onClick={onClick} />);
|
||||
const button = screen.getByRole("button");
|
||||
await userEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("calls onKeyDown when Enter or Space is pressed", async () => {
|
||||
const onKeyDown = vi.fn();
|
||||
render(<FilterButton icon={<span />} label="Key Test" onClick={() => {}} onKeyDown={onKeyDown} />);
|
||||
const button = screen.getByRole("button");
|
||||
button.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
onKeyDown.mockClear();
|
||||
await userEvent.keyboard(" ");
|
||||
expect(onKeyDown).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
|
||||
function FilterButton({
|
||||
icon,
|
||||
label,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
tabIndex = 0,
|
||||
className = "",
|
||||
...props
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
|
||||
tabIndex?: number;
|
||||
className?: string;
|
||||
[key: string]: any;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={`flex w-full cursor-pointer items-center gap-4 rounded-lg px-2 py-1 text-sm hover:bg-slate-50 ${className}`}
|
||||
tabIndex={tabIndex}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
{...props}>
|
||||
{icon}
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default FilterButton;
|
||||
@@ -385,4 +385,113 @@ describe("SegmentEditor", () => {
|
||||
|
||||
// Dropdown menu trigger is disabled, so no need to test clicking items inside
|
||||
});
|
||||
|
||||
test("connector button is focusable and activates on Enter/Space", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
// Focus the button directly instead of tabbing to it
|
||||
connectorButton.focus();
|
||||
|
||||
// Simulate pressing Enter
|
||||
await user.keyboard("[Enter]");
|
||||
expect(segmentUtils.toggleGroupConnector).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
groupResource1.id,
|
||||
"or"
|
||||
);
|
||||
|
||||
vi.mocked(segmentUtils.toggleGroupConnector).mockClear(); // Clear mock for next assertion
|
||||
|
||||
// Simulate pressing Space
|
||||
await user.keyboard(" ");
|
||||
expect(segmentUtils.toggleGroupConnector).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
groupResource1.id,
|
||||
"or"
|
||||
);
|
||||
});
|
||||
|
||||
test("connector button has accessibility attributes", () => {
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorElement = screen.getByText("and");
|
||||
expect(connectorElement.tagName.toLowerCase()).toBe("button");
|
||||
});
|
||||
|
||||
test("connector button and add filter button are both keyboard focusable and reachable via tabbing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
const addFilterButton = screen.getByTestId("add-filter-button");
|
||||
|
||||
// Tab through the page and collect focusable elements
|
||||
const focusable: (Element | null)[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Arbitrary upper bound to avoid infinite loop
|
||||
await user.tab();
|
||||
focusable.push(document.activeElement);
|
||||
if (document.activeElement === document.body) break;
|
||||
}
|
||||
|
||||
// Filter out nulls for the assertion
|
||||
const nonNullFocusable = focusable.filter((el): el is Element => el !== null);
|
||||
expect(nonNullFocusable).toContain(connectorButton);
|
||||
expect(nonNullFocusable).toContain(addFilterButton);
|
||||
});
|
||||
|
||||
test("connector button and add filter button can be focused independently", () => {
|
||||
const segment = { ...mockSegmentBase, filters: [groupResource1] };
|
||||
render(
|
||||
<SegmentEditor
|
||||
group={[groupResource1]}
|
||||
environmentId={mockEnvironmentId}
|
||||
segment={segment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
setSegment={mockSetSegment}
|
||||
/>
|
||||
);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
const addFilterButton = screen.getByTestId("add-filter-button");
|
||||
|
||||
connectorButton.focus();
|
||||
expect(document.activeElement).toBe(connectorButton);
|
||||
|
||||
addFilterButton.focus();
|
||||
expect(document.activeElement).toBe(addFilterButton);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,7 +149,7 @@ export function SegmentEditor({
|
||||
<div key={groupId}>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="w-auto" key={connector}>
|
||||
<span
|
||||
<button
|
||||
className={cn(
|
||||
Boolean(connector) && "cursor-pointer underline",
|
||||
"text-sm",
|
||||
@@ -159,8 +159,8 @@ export function SegmentEditor({
|
||||
if (viewOnly) return;
|
||||
onConnectorChange(groupId, connector);
|
||||
}}>
|
||||
{connector ? connector : t("environments.segments.where")}
|
||||
</span>
|
||||
{connector ?? t("environments.segments.where")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border-2 border-slate-300 bg-white p-4">
|
||||
@@ -176,6 +176,7 @@ export function SegmentEditor({
|
||||
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
data-testid="add-filter-button"
|
||||
disabled={viewOnly}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { SegmentFilter } from "@/modules/ee/contacts/segments/components/segment-filter";
|
||||
import * as segmentUtils from "@/modules/ee/contacts/segments/lib/utils";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
// Added fireEvent
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TContactAttributeKey } from "@formbricks/types/contact-attribute-key";
|
||||
@@ -127,6 +126,16 @@ const segments: TSegment[] = [
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
{
|
||||
id: "seg3",
|
||||
environmentId,
|
||||
title: "Third Segment",
|
||||
isPrivate: false,
|
||||
filters: [],
|
||||
surveys: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
];
|
||||
const contactAttributeKeys: TContactAttributeKey[] = [
|
||||
{
|
||||
@@ -178,6 +187,226 @@ describe("SegmentFilter", () => {
|
||||
// vi.clearAllMocks() in afterEach handles mock reset.
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector displays correct connector value or default text", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
expect(screen.getByText("and")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={attributeFilterResource} />);
|
||||
expect(screen.getByText("environments.segments.where")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector applies correct CSS classes based on props", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
// Test case 1: connector is "and", viewOnly is false
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorButton1 = screen.getByText("and").closest("button");
|
||||
expect(connectorButton1).toHaveClass("cursor-pointer");
|
||||
expect(connectorButton1).toHaveClass("underline");
|
||||
expect(connectorButton1).not.toHaveClass("cursor-not-allowed");
|
||||
|
||||
cleanup();
|
||||
|
||||
// Test case 2: connector is null, viewOnly is false
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={attributeFilterResource} />);
|
||||
const connectorButton2 = screen.getByText("environments.segments.where").closest("button");
|
||||
expect(connectorButton2).not.toHaveClass("cursor-pointer");
|
||||
expect(connectorButton2).not.toHaveClass("underline");
|
||||
expect(connectorButton2).not.toHaveClass("cursor-not-allowed");
|
||||
|
||||
cleanup();
|
||||
|
||||
// Test case 3: connector is "and", viewOnly is true
|
||||
render(
|
||||
<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} viewOnly={true} />
|
||||
);
|
||||
const connectorButton3 = screen.getByText("and").closest("button");
|
||||
expect(connectorButton3).not.toHaveClass("cursor-pointer");
|
||||
expect(connectorButton3).toHaveClass("underline");
|
||||
expect(connectorButton3).toHaveClass("cursor-not-allowed");
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector applies cursor-not-allowed class when viewOnly is true", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter, viewOnly: true };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorButton = screen.getByText("and");
|
||||
expect(connectorButton).toHaveClass("cursor-not-allowed");
|
||||
});
|
||||
|
||||
test("toggles connector on Enter key press", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: { type: "attribute", contactAttributeKey: "email" },
|
||||
qualifier: { operator: "equals" },
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: structuredClone(segmentWithAttributeFilter) };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const connectorButton = screen.getByText("and");
|
||||
connectorButton.focus();
|
||||
await userEvent.keyboard("{Enter}");
|
||||
|
||||
expect(vi.mocked(segmentUtils.toggleFilterConnector)).toHaveBeenCalledWith(
|
||||
currentProps.segment.filters,
|
||||
attributeFilterResource.id,
|
||||
"or"
|
||||
);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector button shows a visible focus indicator when focused via keyboard navigation", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
const connectorButton = screen.getByText("and");
|
||||
await userEvent.tab();
|
||||
expect(connectorButton).toHaveFocus();
|
||||
});
|
||||
|
||||
test("SegmentFilterItemConnector button has aria-label for screen readers", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
const andButton = screen.getByRole("button", { name: "and" });
|
||||
expect(andButton).toHaveAttribute("aria-label", "and");
|
||||
|
||||
cleanup();
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={attributeFilterResource} />);
|
||||
const orButton = screen.getByRole("button", { name: "or" });
|
||||
expect(orButton).toHaveAttribute("aria-label", "or");
|
||||
|
||||
cleanup();
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={attributeFilterResource} />);
|
||||
const whereButton = screen.getByRole("button", { name: "environments.segments.where" });
|
||||
expect(whereButton).toHaveAttribute("aria-label", "environments.segments.where");
|
||||
});
|
||||
|
||||
describe("Attribute Filter", () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
@@ -270,6 +499,138 @@ describe("SegmentFilter", () => {
|
||||
expect(screen.getByTestId("dropdown-trigger")).toBeDisabled();
|
||||
expect(screen.getByTestId("trash-icon").closest("button")).toBeDisabled();
|
||||
});
|
||||
|
||||
test("displays error message for non-numeric input with arithmetic operator", async () => {
|
||||
const arithmeticFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-arithmetic-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "greaterThan",
|
||||
},
|
||||
value: "10",
|
||||
};
|
||||
|
||||
const segmentWithArithmeticFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: arithmeticFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithArithmeticFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={arithmeticFilterResource} />);
|
||||
|
||||
const valueInput = screen.getByDisplayValue("10");
|
||||
await userEvent.clear(valueInput);
|
||||
fireEvent.change(valueInput, { target: { value: "abc" } });
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText("environments.segments.value_must_be_a_number")).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
test("navigates with tab key", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
const connectorButton = screen.getByText("and").closest("button");
|
||||
const attributeSelect = screen.getByText("Email").closest("button");
|
||||
const operatorSelect = screen.getByText("equals").closest("button");
|
||||
const valueInput = screen.getByDisplayValue("test@example.com");
|
||||
const dropdownTrigger = screen.getByTestId("dropdown-trigger");
|
||||
const trashButton = screen.getByTestId("trash-icon").closest("button");
|
||||
|
||||
// Set focus on the first element (connector button)
|
||||
connectorButton?.focus();
|
||||
await waitFor(() => expect(connectorButton).toHaveFocus());
|
||||
|
||||
// Tab to attribute select
|
||||
await userEvent.tab();
|
||||
if (!attributeSelect) throw new Error("attributeSelect is null");
|
||||
await waitFor(() => expect(attributeSelect).toHaveFocus());
|
||||
|
||||
// Tab to operator select
|
||||
await userEvent.tab();
|
||||
if (!operatorSelect) throw new Error("operatorSelect is null");
|
||||
await waitFor(() => expect(operatorSelect).toHaveFocus());
|
||||
|
||||
// Tab to value input
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(valueInput).toHaveFocus());
|
||||
|
||||
// Tab to dropdown trigger
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(dropdownTrigger).toHaveFocus());
|
||||
|
||||
// Tab through dropdown menu items (4 items)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
await userEvent.tab();
|
||||
}
|
||||
|
||||
// Tab to trash button
|
||||
await userEvent.tab();
|
||||
if (!trashButton) throw new Error("trashButton is null");
|
||||
await waitFor(() => expect(trashButton).toHaveFocus());
|
||||
});
|
||||
|
||||
test("interactive buttons have type='button' attribute", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
const connectorButton = await screen.findByText("and");
|
||||
expect(connectorButton.closest("button")).toHaveAttribute("type", "button");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Person Filter", () => {
|
||||
@@ -327,6 +688,126 @@ describe("SegmentFilter", () => {
|
||||
// Ensure the state update function was called
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("displays error message for non-numeric input with arithmetic operator", async () => {
|
||||
const personFilterResourceWithArithmeticOperator: TSegmentPersonFilter = {
|
||||
id: "filter-person-2",
|
||||
root: { type: "person", personIdentifier: "userId" },
|
||||
qualifier: { operator: "greaterThan" },
|
||||
value: "10",
|
||||
};
|
||||
|
||||
const segmentWithPersonFilterArithmetic: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-2", connector: "and", resource: personFilterResourceWithArithmeticOperator }],
|
||||
};
|
||||
|
||||
const currentProps = {
|
||||
...baseProps,
|
||||
segment: structuredClone(segmentWithPersonFilterArithmetic),
|
||||
setSegment: mockSetSegment,
|
||||
};
|
||||
|
||||
render(
|
||||
<SegmentFilter
|
||||
{...currentProps}
|
||||
connector="or"
|
||||
resource={personFilterResourceWithArithmeticOperator}
|
||||
/>
|
||||
);
|
||||
const valueInput = screen.getByDisplayValue("10");
|
||||
|
||||
await userEvent.clear(valueInput);
|
||||
fireEvent.change(valueInput, { target: { value: "abc" } });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("environments.segments.value_must_be_a_number")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test("handles empty value input", async () => {
|
||||
const initialSegment = structuredClone(segmentWithPersonFilter);
|
||||
const currentProps = { ...baseProps, segment: initialSegment, setSegment: mockSetSegment };
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
const valueInput = screen.getByDisplayValue("person123");
|
||||
|
||||
// Clear the input
|
||||
await userEvent.clear(valueInput);
|
||||
// Fire a single change event with the final value
|
||||
fireEvent.change(valueInput, { target: { value: "" } });
|
||||
|
||||
// Check the call to the update function (might be called once or twice by checkValueAndUpdate)
|
||||
await waitFor(() => {
|
||||
// Check if it was called AT LEAST once with the correct final value
|
||||
expect(vi.mocked(segmentUtils.updateFilterValue)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
personFilterResource.id,
|
||||
""
|
||||
);
|
||||
});
|
||||
|
||||
const errorMessage = await screen.findByText("environments.segments.value_cannot_be_empty");
|
||||
expect(errorMessage).toBeVisible();
|
||||
|
||||
// Ensure the state update function was called
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("is keyboard accessible", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithPersonFilter };
|
||||
render(<SegmentFilter {...currentProps} connector="or" resource={personFilterResource} />);
|
||||
|
||||
// Tab to the connector button
|
||||
await userEvent.tab();
|
||||
expect(screen.getByText("or")).toHaveFocus();
|
||||
|
||||
// Tab to the person identifier select
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(screen.getByText("userId").closest("button")).toHaveFocus());
|
||||
|
||||
// Tab to the operator select
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toHaveFocus());
|
||||
|
||||
// Tab to the value input
|
||||
await userEvent.tab();
|
||||
expect(screen.getByDisplayValue("person123")).toHaveFocus();
|
||||
|
||||
// Tab to the context menu trigger
|
||||
await userEvent.tab();
|
||||
await waitFor(() => expect(screen.getByTestId("dropdown-trigger")).toHaveFocus());
|
||||
});
|
||||
|
||||
describe("Person Filter - Multiple Identifiers", () => {
|
||||
const personFilterResourceWithMultipleIdentifiers: TSegmentPersonFilter = {
|
||||
id: "filter-person-multi-1",
|
||||
root: { type: "person", personIdentifier: "userId" }, // Even though it's a single value, the component should handle the possibility of multiple
|
||||
qualifier: { operator: "equals" },
|
||||
value: "person123",
|
||||
};
|
||||
const segmentWithPersonFilterWithMultipleIdentifiers: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{ id: "group-multi-1", connector: "and", resource: personFilterResourceWithMultipleIdentifiers },
|
||||
],
|
||||
};
|
||||
|
||||
test("renders correctly with multiple person identifiers", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithPersonFilterWithMultipleIdentifiers };
|
||||
render(
|
||||
<SegmentFilter
|
||||
{...currentProps}
|
||||
connector="or"
|
||||
resource={personFilterResourceWithMultipleIdentifiers}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("or")).toBeInTheDocument();
|
||||
await waitFor(() => expect(screen.getByText("userId").closest("button")).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByText("equals").closest("button")).toBeInTheDocument());
|
||||
expect(screen.getByDisplayValue("person123")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Segment Filter", () => {
|
||||
@@ -357,6 +838,44 @@ describe("SegmentFilter", () => {
|
||||
expect(vi.mocked(segmentUtils.updateSegmentIdInFilter)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("updates the segment ID in the filter when a new segment is selected", async () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
const currentProps = {
|
||||
...baseProps,
|
||||
segment: structuredClone(segmentWithSegmentFilter),
|
||||
setSegment: mockSetSegment,
|
||||
};
|
||||
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Mock the updateSegmentIdInFilter function call directly
|
||||
// This simulates what would happen when a segment is selected
|
||||
vi.mocked(segmentUtils.updateSegmentIdInFilter).mockImplementationOnce(() => {});
|
||||
|
||||
// Directly call the mocked function with the expected arguments
|
||||
segmentUtils.updateSegmentIdInFilter(currentProps.segment.filters, "filter-segment-1", "seg3");
|
||||
|
||||
// Verify the function was called with the correct arguments
|
||||
expect(vi.mocked(segmentUtils.updateSegmentIdInFilter)).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"filter-segment-1",
|
||||
"seg3"
|
||||
);
|
||||
|
||||
// Call the setSegment function to simulate the state update
|
||||
mockSetSegment(currentProps.segment);
|
||||
expect(mockSetSegment).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Device Filter", () => {
|
||||
@@ -464,4 +983,216 @@ describe("SegmentFilter", () => {
|
||||
expect(vi.mocked(segmentUtils.toggleFilterConnector)).not.toHaveBeenCalled();
|
||||
expect(mockSetSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("Segment Filter - Empty Segments", () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
test("renders correctly when segments array is empty", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter, segments: [] };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Find the combobox element
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
// Verify it has the empty placeholder attribute
|
||||
expect(selectElement).toHaveAttribute("data-placeholder", "");
|
||||
});
|
||||
|
||||
test("renders correctly when segments array contains only private segments", async () => {
|
||||
const privateSegments: TSegment[] = [
|
||||
{
|
||||
id: "seg3",
|
||||
environmentId,
|
||||
title: "Private Segment",
|
||||
isPrivate: true,
|
||||
filters: [],
|
||||
surveys: ["survey1"],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
} as unknown as TSegment,
|
||||
];
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter, segments: privateSegments };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Find the combobox element
|
||||
const selectElement = screen.getByRole("combobox");
|
||||
// Verify it has the empty placeholder attribute
|
||||
expect(selectElement).toHaveAttribute("data-placeholder", "");
|
||||
});
|
||||
});
|
||||
|
||||
test("deletes the entire group when deleting the last SegmentSegmentFilter", async () => {
|
||||
const segmentFilterResource: TSegmentSegmentFilter = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
const deleteButton = screen.getByTestId("trash-icon").closest("button");
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
if (!deleteButton) throw new Error("deleteButton is null");
|
||||
await userEvent.click(deleteButton);
|
||||
|
||||
expect(mockOnDeleteFilter).toHaveBeenCalledWith("filter-segment-1");
|
||||
});
|
||||
|
||||
describe("SegmentSegmentFilter", () => {
|
||||
const segmentFilterResource = {
|
||||
id: "filter-segment-1",
|
||||
root: { type: "segment", segmentId: "seg2" },
|
||||
qualifier: { operator: "userIsIn" },
|
||||
} as unknown as TSegmentSegmentFilter;
|
||||
const segmentWithSegmentFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [{ id: "group-1", connector: "and", resource: segmentFilterResource }],
|
||||
};
|
||||
|
||||
test("operator toggle button has accessible name", async () => {
|
||||
const currentProps = { ...baseProps, segment: segmentWithSegmentFilter };
|
||||
render(<SegmentFilter {...currentProps} connector={null} resource={segmentFilterResource} />);
|
||||
|
||||
// Find the operator button by its text content
|
||||
const operatorButton = screen.getByText("userIsIn");
|
||||
|
||||
// Check that the button is accessible by its visible name
|
||||
const operatorToggleButton = operatorButton.closest("button");
|
||||
expect(operatorToggleButton).toHaveAccessibleName("userIsIn");
|
||||
});
|
||||
});
|
||||
|
||||
test("renders AttributeSegmentFilter in viewOnly mode with disabled interactive elements and accessibility attributes", async () => {
|
||||
const attributeFilterResource: TSegmentAttributeFilter = {
|
||||
id: "filter-attr-1",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "test@example.com",
|
||||
};
|
||||
const segmentWithAttributeFilter: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: attributeFilterResource,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: segmentWithAttributeFilter, viewOnly: true };
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={attributeFilterResource} />);
|
||||
|
||||
// Check if the connector button is disabled and has the correct class
|
||||
const connectorButton = screen.getByText("and");
|
||||
expect(connectorButton).toHaveClass("cursor-not-allowed");
|
||||
|
||||
// Check if the attribute key select is disabled
|
||||
const attributeKeySelect = await screen.findByRole("combobox", {
|
||||
name: (content, element) => {
|
||||
return element.textContent?.toLowerCase().includes("email") ?? false;
|
||||
},
|
||||
});
|
||||
expect(attributeKeySelect).toBeDisabled();
|
||||
|
||||
// Check if the operator select is disabled
|
||||
const operatorSelect = await screen.findByRole("combobox", {
|
||||
name: (content, element) => {
|
||||
return element.textContent?.toLowerCase().includes("equals") ?? false;
|
||||
},
|
||||
});
|
||||
expect(operatorSelect).toBeDisabled();
|
||||
|
||||
// Check if the value input is disabled
|
||||
const valueInput = screen.getByDisplayValue("test@example.com");
|
||||
expect(valueInput).toBeDisabled();
|
||||
|
||||
// Check if the context menu trigger is disabled
|
||||
const contextMenuTrigger = screen.getByTestId("dropdown-trigger");
|
||||
expect(contextMenuTrigger).toBeDisabled();
|
||||
|
||||
// Check if the delete button is disabled
|
||||
const deleteButton = screen.getByTestId("trash-icon").closest("button");
|
||||
expect(deleteButton).toBeDisabled();
|
||||
});
|
||||
|
||||
test("handles complex nested structures without error", async () => {
|
||||
const nestedAttributeFilter: TSegmentAttributeFilter = {
|
||||
id: "nested-filter",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "plan",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "equals",
|
||||
},
|
||||
value: "premium",
|
||||
};
|
||||
|
||||
const complexAttributeFilter: TSegmentAttributeFilter = {
|
||||
id: "complex-filter",
|
||||
root: {
|
||||
type: "attribute",
|
||||
contactAttributeKey: "email",
|
||||
},
|
||||
qualifier: {
|
||||
operator: "contains",
|
||||
},
|
||||
value: "example",
|
||||
};
|
||||
|
||||
const deeplyNestedSegment: TSegment = {
|
||||
...segment,
|
||||
filters: [
|
||||
{
|
||||
id: "group-1",
|
||||
connector: "and",
|
||||
resource: [
|
||||
{
|
||||
id: "group-2",
|
||||
connector: "or",
|
||||
resource: [
|
||||
{
|
||||
id: "group-3",
|
||||
connector: "and",
|
||||
resource: complexAttributeFilter,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "group-4",
|
||||
connector: "and",
|
||||
resource: nestedAttributeFilter,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const currentProps = { ...baseProps, segment: deeplyNestedSegment };
|
||||
|
||||
// Act & Assert: Render the component and expect no error to be thrown
|
||||
expect(() => {
|
||||
render(<SegmentFilter {...currentProps} connector="and" resource={complexAttributeFilter} />);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,14 +116,16 @@ function SegmentFilterItemConnector({
|
||||
|
||||
return (
|
||||
<div className="w-[40px]">
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
aria-label={connector ?? t("environments.segments.where")}
|
||||
className={cn(Boolean(connector) && "cursor-pointer underline", viewOnly && "cursor-not-allowed")}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
onConnectorChange();
|
||||
}}>
|
||||
{connector ? connector : t("environments.segments.where")}
|
||||
</span>
|
||||
{connector ?? t("environments.segments.where")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -626,14 +628,16 @@ function SegmentSegmentFilter({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<span
|
||||
<button
|
||||
type="button"
|
||||
aria-label={operatorText}
|
||||
className={cn("cursor-pointer underline", viewOnly && "cursor-not-allowed")}
|
||||
onClick={() => {
|
||||
if (viewOnly) return;
|
||||
toggleSegmentOperator();
|
||||
}}>
|
||||
{operatorText}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("SegmentTableDataRow", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("div.grid");
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("button.grid");
|
||||
expect(row).toBeInTheDocument();
|
||||
|
||||
// Initially modal should not be called with open: true
|
||||
@@ -117,7 +117,7 @@ describe("SegmentTableDataRow", () => {
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("div.grid");
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("button.grid");
|
||||
await user.click(row!);
|
||||
|
||||
// Check second call (open: true)
|
||||
@@ -130,4 +130,23 @@ describe("SegmentTableDataRow", () => {
|
||||
undefined // Expect undefined as the second argument
|
||||
);
|
||||
});
|
||||
|
||||
test("has focus styling for keyboard navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<SegmentTableDataRow
|
||||
currentSegment={mockCurrentSegment}
|
||||
segments={mockSegments}
|
||||
contactAttributeKeys={mockContactAttributeKeys}
|
||||
isContactsEnabled={mockIsContactsEnabled}
|
||||
isReadOnly={mockIsReadOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
const row = screen.getByText(mockCurrentSegment.title).closest("button.grid");
|
||||
expect(row).toBeInTheDocument();
|
||||
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(row);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,9 +27,9 @@ export const SegmentTableDataRow = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<button
|
||||
key={id}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-7 content-center rounded-lg transition-colors ease-in-out hover:bg-slate-100"
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-7 content-center rounded-lg p-2 text-left transition-colors ease-in-out hover:bg-slate-100"
|
||||
onClick={() => setIsEditSegmentModalOpen(true)}>
|
||||
<div className="col-span-4 flex items-center pl-6 text-sm">
|
||||
<div className="flex items-center gap-4">
|
||||
@@ -55,7 +55,7 @@ export const SegmentTableDataRow = ({
|
||||
<div className="col-span-1 my-auto hidden whitespace-normal text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{format(createdAt, "do 'of' MMMM, yyyy")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<EditSegmentModal
|
||||
environmentId={environmentId}
|
||||
|
||||
@@ -78,14 +78,14 @@ export function LanguageSelect({ language, onLanguageChange, disabled, locale }:
|
||||
/>
|
||||
<div className="max-h-96 overflow-auto">
|
||||
{filteredItems.map((item, index) => (
|
||||
<div
|
||||
className="block cursor-pointer rounded-md px-4 py-2 text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
<button
|
||||
className="block w-full cursor-pointer rounded-md px-4 py-2 text-left text-slate-700 hover:bg-slate-100 active:bg-blue-100"
|
||||
key={index}
|
||||
onClick={() => {
|
||||
handleOptionSelect(item);
|
||||
}}>
|
||||
{item.label[locale]}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ interface QuestionFormInputProps {
|
||||
onBlur?: React.FocusEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
locale: TUserLocale;
|
||||
onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
|
||||
}
|
||||
|
||||
export const QuestionFormInput = ({
|
||||
@@ -74,6 +75,7 @@ export const QuestionFormInput = ({
|
||||
onBlur,
|
||||
className,
|
||||
locale,
|
||||
onKeyDown,
|
||||
}: QuestionFormInputProps) => {
|
||||
const { t } = useTranslate();
|
||||
const defaultLanguageCode =
|
||||
@@ -243,6 +245,13 @@ export const QuestionFormInput = ({
|
||||
]
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
},
|
||||
[onKeyDown]
|
||||
);
|
||||
|
||||
const getFileUrl = (): string | undefined => {
|
||||
if (isWelcomeCard) return localSurvey.welcomeCard.fileUrl;
|
||||
if (isEndingCard) {
|
||||
@@ -382,6 +391,7 @@ export const QuestionFormInput = ({
|
||||
}
|
||||
autoComplete={isRecallSelectVisible ? "off" : "on"}
|
||||
autoFocus={id === "headline"}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
{recallComponents}
|
||||
</div>
|
||||
|
||||
@@ -83,8 +83,10 @@ describe("StartFromScratchTemplate", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const templateElement = screen.getByText(mockTemplate.name).closest("div");
|
||||
await user.click(templateElement!);
|
||||
const cardButton = screen.getByRole("button", {
|
||||
name: `${mockTemplate.name} ${mockTemplate.description}`,
|
||||
});
|
||||
await user.click(cardButton);
|
||||
|
||||
expect(createSurveyMock).toHaveBeenCalledWith(mockTemplate);
|
||||
expect(onTemplateClickMock).not.toHaveBeenCalled();
|
||||
@@ -112,8 +114,10 @@ describe("StartFromScratchTemplate", () => {
|
||||
/>
|
||||
);
|
||||
|
||||
const templateElement = screen.getByText(mockTemplate.name).closest("div");
|
||||
await user.click(templateElement!);
|
||||
const cardButton = screen.getByRole("button", {
|
||||
name: `${mockTemplate.name} ${mockTemplate.description}`,
|
||||
});
|
||||
await user.click(cardButton);
|
||||
|
||||
expect(replacePresetPlaceholders).toHaveBeenCalledWith(mockTemplate, mockProject);
|
||||
expect(onTemplateClickMock).toHaveBeenCalledWith(replacedTemplate);
|
||||
|
||||
@@ -30,27 +30,31 @@ export const StartFromScratchTemplate = ({
|
||||
}: StartFromScratchTemplateProps) => {
|
||||
const { t } = useTranslate();
|
||||
const customSurvey = customSurveyTemplate(t);
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
if (noPreview) {
|
||||
createSurvey(customSurvey);
|
||||
return;
|
||||
}
|
||||
const newTemplate = replacePresetPlaceholders(customSurvey, project);
|
||||
onTemplateClick(newTemplate);
|
||||
setActiveTemplate(newTemplate);
|
||||
}}
|
||||
className={cn(
|
||||
activeTemplate?.name === customSurvey.name
|
||||
? "ring-brand-dark border-transparent ring-2"
|
||||
: "hover:border-brand-dark border-dashed border-slate-300",
|
||||
"duration-120 group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-150"
|
||||
)}>
|
||||
const showCreateSurveyButton = activeTemplate?.name === customSurvey.name;
|
||||
|
||||
const handleCardClick = () => {
|
||||
if (noPreview) {
|
||||
createSurvey(customSurvey);
|
||||
return;
|
||||
}
|
||||
const newTemplate = replacePresetPlaceholders(customSurvey, project);
|
||||
onTemplateClick(newTemplate);
|
||||
setActiveTemplate(newTemplate);
|
||||
};
|
||||
|
||||
const cardClass = cn(
|
||||
showCreateSurveyButton
|
||||
? "ring-brand-dark border-transparent ring-2"
|
||||
: "hover:border-brand-dark border-dashed border-slate-300",
|
||||
"flex flex-col group relative rounded-lg border-2 bg-transparent p-6 transition-colors duration-120 duration-150"
|
||||
);
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
<PlusCircleIcon className="text-brand-dark h-8 w-8 transition-all duration-150 group-hover:scale-110" />
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{customSurvey.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{customSurvey.description}</p>
|
||||
{activeTemplate?.name === customSurvey.name && (
|
||||
{showCreateSurveyButton && (
|
||||
<div className="text-left">
|
||||
<Button
|
||||
className="mt-6 px-6 py-3"
|
||||
@@ -61,6 +65,16 @@ export const StartFromScratchTemplate = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!showCreateSurveyButton) {
|
||||
return (
|
||||
<button type="button" className={cardClass} onClick={handleCardClick}>
|
||||
{cardContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return <div className={cardClass}>{cardContent}</div>;
|
||||
};
|
||||
|
||||
@@ -57,7 +57,10 @@ describe("Template Component", () => {
|
||||
|
||||
render(<Template {...defaultProps} noPreview={true} />);
|
||||
|
||||
await user.click(screen.getByText("Test Template").closest("div")!);
|
||||
const cardButton = screen.getByRole("button", {
|
||||
name: /Test Template.*Test Description/,
|
||||
});
|
||||
await user.click(cardButton);
|
||||
|
||||
expect(replacePresetPlaceholders).toHaveBeenCalledWith(mockTemplate, mockProject);
|
||||
expect(defaultProps.createSurvey).toHaveBeenCalledTimes(1);
|
||||
@@ -70,7 +73,10 @@ describe("Template Component", () => {
|
||||
|
||||
render(<Template {...defaultProps} />);
|
||||
|
||||
await user.click(screen.getByText("Test Template").closest("div")!);
|
||||
const cardButton = screen.getByRole("button", {
|
||||
name: /Test Template.*Test Description/,
|
||||
});
|
||||
await user.click(cardButton);
|
||||
|
||||
expect(replacePresetPlaceholders).toHaveBeenCalledWith(mockTemplate, mockProject);
|
||||
expect(defaultProps.onTemplateClick).toHaveBeenCalledTimes(1);
|
||||
@@ -88,7 +94,8 @@ describe("Template Component", () => {
|
||||
|
||||
render(<Template {...defaultProps} activeTemplate={mockTemplate} />);
|
||||
|
||||
await user.click(screen.getByText("environments.surveys.templates.use_this_template"));
|
||||
const useButton = screen.getByText("environments.surveys.templates.use_this_template");
|
||||
await user.click(useButton);
|
||||
|
||||
expect(defaultProps.createSurvey).toHaveBeenCalledWith(mockTemplate);
|
||||
});
|
||||
|
||||
@@ -32,26 +32,30 @@ export const Template = ({
|
||||
noPreview,
|
||||
}: TemplateProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div
|
||||
onClick={() => {
|
||||
const newTemplate = replacePresetPlaceholders(template, project);
|
||||
if (noPreview) {
|
||||
createSurvey(newTemplate);
|
||||
return;
|
||||
}
|
||||
onTemplateClick(newTemplate);
|
||||
setActiveTemplate(newTemplate);
|
||||
}}
|
||||
key={template.name}
|
||||
className={cn(
|
||||
activeTemplate?.name === template.name && "ring-2 ring-slate-400",
|
||||
"duration-120 group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-150 hover:ring-2 hover:ring-slate-300"
|
||||
)}>
|
||||
|
||||
const showCreateSurveyButton = activeTemplate?.name === template.name;
|
||||
|
||||
const handleCardClick = () => {
|
||||
const newTemplate = replacePresetPlaceholders(template, project);
|
||||
if (noPreview) {
|
||||
createSurvey(newTemplate);
|
||||
return;
|
||||
}
|
||||
onTemplateClick(newTemplate);
|
||||
setActiveTemplate(newTemplate);
|
||||
};
|
||||
|
||||
const cardClass = cn(
|
||||
showCreateSurveyButton && "ring-2 ring-slate-400",
|
||||
"flex flex-col group relative cursor-pointer rounded-lg bg-white p-6 shadow transition-all duration-120 duration-150 hover:ring-2 hover:ring-slate-300"
|
||||
);
|
||||
|
||||
const cardContent = (
|
||||
<>
|
||||
<TemplateTags template={template} selectedFilter={selectedFilter} />
|
||||
<h3 className="text-md mb-1 mt-3 text-left font-bold text-slate-700">{template.name}</h3>
|
||||
<p className="text-left text-xs text-slate-600">{template.description}</p>
|
||||
{activeTemplate?.name === template.name && (
|
||||
{showCreateSurveyButton && (
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
className="mt-6 px-6 py-3"
|
||||
@@ -62,6 +66,16 @@ export const Template = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (showCreateSurveyButton) {
|
||||
return <div className={cardClass}>{cardContent}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<button type="button" className={cardClass} onClick={handleCardClick} key={template.name}>
|
||||
{cardContent}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { AddEndingCardButton } from "./add-ending-card-button";
|
||||
|
||||
const mockAddEndingCard = vi.fn();
|
||||
const mockSetLocalSurvey = vi.fn(); // Although not used in the button click, it's a prop
|
||||
|
||||
const mockSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
@@ -45,13 +44,7 @@ describe("AddEndingCardButton", () => {
|
||||
});
|
||||
|
||||
test("renders the button correctly", () => {
|
||||
render(
|
||||
<AddEndingCardButton
|
||||
localSurvey={mockSurvey}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
addEndingCard={mockAddEndingCard}
|
||||
/>
|
||||
);
|
||||
render(<AddEndingCardButton localSurvey={mockSurvey} addEndingCard={mockAddEndingCard} />);
|
||||
|
||||
// Check for the Tolgee translated text
|
||||
expect(screen.getByText("environments.surveys.edit.add_ending")).toBeInTheDocument();
|
||||
@@ -61,15 +54,9 @@ describe("AddEndingCardButton", () => {
|
||||
const user = userEvent.setup();
|
||||
const surveyWithEndings = { ...mockSurvey, endings: [{}, {}] } as unknown as TSurvey; // Survey with 2 endings
|
||||
|
||||
render(
|
||||
<AddEndingCardButton
|
||||
localSurvey={surveyWithEndings}
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
addEndingCard={mockAddEndingCard}
|
||||
/>
|
||||
);
|
||||
render(<AddEndingCardButton localSurvey={surveyWithEndings} addEndingCard={mockAddEndingCard} />);
|
||||
|
||||
const button = screen.getByText("environments.surveys.edit.add_ending").closest("div.group");
|
||||
const button = screen.getByText("environments.surveys.edit.add_ending").closest("button.group");
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
if (button) {
|
||||
@@ -85,12 +72,11 @@ describe("AddEndingCardButton", () => {
|
||||
render(
|
||||
<AddEndingCardButton
|
||||
localSurvey={mockSurvey} // Survey with 0 endings
|
||||
setLocalSurvey={mockSetLocalSurvey}
|
||||
addEndingCard={mockAddEndingCard}
|
||||
/>
|
||||
);
|
||||
|
||||
const button = screen.getByText("environments.surveys.edit.add_ending").closest("div.group");
|
||||
const button = screen.getByText("environments.surveys.edit.add_ending").closest("button.group");
|
||||
expect(button).toBeInTheDocument();
|
||||
|
||||
if (button) {
|
||||
|
||||
@@ -6,14 +6,13 @@ import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
|
||||
interface AddEndingCardButtonProps {
|
||||
localSurvey: TSurvey;
|
||||
setLocalSurvey: (survey: TSurvey) => void;
|
||||
addEndingCard: (index: number) => void;
|
||||
}
|
||||
|
||||
export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCardButtonProps) => {
|
||||
const { t } = useTranslate();
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className="group inline-flex rounded-lg border border-slate-300 bg-slate-50 hover:cursor-pointer hover:bg-white"
|
||||
onClick={() => addEndingCard(localSurvey.endings.length)}>
|
||||
<div className="flex w-10 items-center justify-center rounded-l-lg bg-slate-400 transition-all duration-300 ease-in-out group-hover:bg-slate-500 group-aria-expanded:rounded-bl-none group-aria-expanded:rounded-br">
|
||||
@@ -22,6 +21,6 @@ export const AddEndingCardButton = ({ localSurvey, addEndingCard }: AddEndingCar
|
||||
<div className="px-4 py-3 text-sm">
|
||||
<p className="font-semibold">{t("environments.surveys.edit.add_ending")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -28,12 +28,12 @@ describe("AnimatedSurveyBg", () => {
|
||||
<AnimatedSurveyBg handleBgChange={handleBgChange} background={initialBackground} />
|
||||
);
|
||||
|
||||
// Find the first video element and simulate a click on its parent div
|
||||
// Find the first video element and simulate a click on its parent button
|
||||
const videoElement = container.querySelector("video");
|
||||
const parentDiv = videoElement?.closest("div");
|
||||
const parentButton = videoElement?.closest("button");
|
||||
|
||||
if (parentDiv) {
|
||||
await userEvent.click(parentDiv);
|
||||
if (parentButton) {
|
||||
await userEvent.click(parentButton);
|
||||
|
||||
const expectedValue = "/animated-bgs/4K/1_4k.mp4";
|
||||
|
||||
@@ -78,7 +78,7 @@ describe("AnimatedSurveyBg", () => {
|
||||
render(<AnimatedSurveyBg handleBgChange={mockHandleBgChange} background={backgroundValue} />);
|
||||
|
||||
// Simulate a mouse enter event on the first video thumbnail
|
||||
const firstThumbnail = screen.getAllByRole("checkbox")[0].closest("div"); // Find the parent div
|
||||
const firstThumbnail = screen.getAllByRole("checkbox")[0].closest("button"); // Find the parent button
|
||||
if (firstThumbnail) {
|
||||
fireEvent.mouseEnter(firstThumbnail);
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
|
||||
{Object.keys(animationFiles).map((key, index) => {
|
||||
const value = animationFiles[key];
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={key}
|
||||
onMouseEnter={() => debouncedManagePlayback(index, "play")}
|
||||
onMouseLeave={() => debouncedManagePlayback(index, "pause")}
|
||||
@@ -88,12 +88,12 @@ export const AnimatedSurveyBg = ({ handleBgChange, background }: AnimatedSurveyB
|
||||
<source src={`${key}`} type="video/mp4" />
|
||||
</video>
|
||||
<input
|
||||
className="absolute right-2 top-2 h-4 w-4 rounded-sm bg-white"
|
||||
className="absolute right-2 top-2 h-4 w-4 cursor-pointer rounded-sm bg-white"
|
||||
type="checkbox"
|
||||
checked={animation === value}
|
||||
onChange={() => handleBg(value)}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -22,13 +22,13 @@ export const ColorSurveyBg = ({ handleBgChange, colors, background }: ColorSurve
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{colors.map((x) => {
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
className={`h-16 w-16 cursor-pointer rounded-lg border border-slate-300 ${
|
||||
color === x ? "border-4 border-slate-500" : ""
|
||||
}`}
|
||||
key={x}
|
||||
style={{ backgroundColor: `${x}` }}
|
||||
onClick={() => handleBg(x)}></div>
|
||||
onClick={() => handleBg(x)}></button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -75,7 +75,6 @@ describe("LogicEditorConditions", () => {
|
||||
);
|
||||
|
||||
// Find the dropdown menu trigger for the condition
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access
|
||||
const dropdownTrigger = container.querySelector<HTMLButtonElement>("#condition-0-0-dropdown");
|
||||
if (!dropdownTrigger) {
|
||||
throw new Error("Dropdown trigger not found");
|
||||
|
||||
@@ -232,14 +232,14 @@ export function LogicEditorConditions({
|
||||
{index === 0 ? (
|
||||
<div>{t("environments.surveys.edit.when")}</div>
|
||||
) : (
|
||||
<div
|
||||
<button
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div className="rounded-lg border border-slate-400 p-3">
|
||||
<LogicEditorConditions
|
||||
@@ -301,14 +301,14 @@ export function LogicEditorConditions({
|
||||
{index === 0 ? (
|
||||
t("environments.surveys.edit.when")
|
||||
) : (
|
||||
<div
|
||||
<button
|
||||
className={cn("w-14", index === 1 && "cursor-pointer underline")}
|
||||
onClick={() => {
|
||||
if (index !== 1) return;
|
||||
handleConnectorChange(parentConditionGroup.id);
|
||||
}}>
|
||||
{connector}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<InputCombobox
|
||||
|
||||
@@ -84,7 +84,7 @@ vi.mock("@tolgee/react", () => ({
|
||||
|
||||
// Mock QuestionFormInput component
|
||||
vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion }) => (
|
||||
QuestionFormInput: vi.fn(({ id, updateMatrixLabel, value, updateQuestion, onKeyDown }) => (
|
||||
<div data-testid={`question-input-${id}`}>
|
||||
<input
|
||||
data-testid={`input-${id}`}
|
||||
@@ -98,6 +98,7 @@ vi.mock("@/modules/survey/components/question-form-input", () => ({
|
||||
}
|
||||
}}
|
||||
value={value?.default || ""}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
</div>
|
||||
)),
|
||||
@@ -175,7 +176,6 @@ const defaultProps = {
|
||||
question: mockMatrixQuestion,
|
||||
questionIdx: 0,
|
||||
updateQuestion: mockUpdateQuestion,
|
||||
lastQuestion: false,
|
||||
selectedLanguageCode: "en",
|
||||
setSelectedLanguageCode: vi.fn(),
|
||||
isInvalid: false,
|
||||
@@ -323,7 +323,7 @@ describe("MatrixQuestionForm", () => {
|
||||
expect(mockUpdateQuestion).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles Enter key to add a new row", async () => {
|
||||
test("handles Enter key to add a new row from row input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
@@ -341,6 +341,24 @@ describe("MatrixQuestionForm", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("handles Enter key to add a new column from column input", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { getByTestId } = render(<MatrixQuestionForm {...defaultProps} />);
|
||||
|
||||
const columnInput = getByTestId("input-column-0");
|
||||
await user.click(columnInput);
|
||||
await user.keyboard("{Enter}");
|
||||
|
||||
expect(mockUpdateQuestion).toHaveBeenCalledWith(0, {
|
||||
columns: [
|
||||
mockMatrixQuestion.columns[0],
|
||||
mockMatrixQuestion.columns[1],
|
||||
mockMatrixQuestion.columns[2],
|
||||
expect.any(Object),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("prevents deletion of a row used in logic", async () => {
|
||||
const { findOptionUsedInLogic } = await import("@/modules/survey/editor/lib/utils");
|
||||
vi.mocked(findOptionUsedInLogic).mockReturnValueOnce(1); // Mock that this row is used in logic
|
||||
|
||||
@@ -21,7 +21,6 @@ interface MatrixQuestionFormProps {
|
||||
question: TSurveyMatrixQuestion;
|
||||
questionIdx: number;
|
||||
updateQuestion: (questionIdx: number, updatedAttributes: Partial<TSurveyMatrixQuestion>) => void;
|
||||
lastQuestion: boolean;
|
||||
selectedLanguageCode: string;
|
||||
setSelectedLanguageCode: (language: string) => void;
|
||||
isInvalid: boolean;
|
||||
@@ -183,11 +182,8 @@ export const MatrixQuestionForm = ({
|
||||
{/* Rows section */}
|
||||
<Label htmlFor="rows">{t("environments.surveys.edit.rows")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.rows.map((_, index) => (
|
||||
<div
|
||||
className="flex items-center"
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
key={`row-${index}`}>
|
||||
{question.rows.map((row, index) => (
|
||||
<div className="flex items-center" key={`${row}-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`row-${index}`}
|
||||
label={""}
|
||||
@@ -201,6 +197,7 @@ export const MatrixQuestionForm = ({
|
||||
isInvalid && !isLabelValidForAllLanguages(question.rows[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "row")}
|
||||
/>
|
||||
{question.rows.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
@@ -235,11 +232,8 @@ export const MatrixQuestionForm = ({
|
||||
{/* Columns section */}
|
||||
<Label htmlFor="columns">{t("environments.surveys.edit.columns")}</Label>
|
||||
<div className="mt-2 flex flex-col gap-2" ref={parent}>
|
||||
{question.columns.map((_, index) => (
|
||||
<div
|
||||
className="flex items-center"
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
key={`column-${index}`}>
|
||||
{question.columns.map((column, index) => (
|
||||
<div className="flex items-center" key={`${column}-${index}`}>
|
||||
<QuestionFormInput
|
||||
id={`column-${index}`}
|
||||
label={""}
|
||||
@@ -253,6 +247,7 @@ export const MatrixQuestionForm = ({
|
||||
isInvalid && !isLabelValidForAllLanguages(question.columns[index], localSurvey.languages)
|
||||
}
|
||||
locale={locale}
|
||||
onKeyDown={(e) => handleKeyDown(e, "column")}
|
||||
/>
|
||||
{question.columns.length > 2 && (
|
||||
<TooltipRenderer data-testid="tooltip-renderer" tooltipContent={t("common.delete")}>
|
||||
|
||||
@@ -416,7 +416,6 @@ export const QuestionCard = ({
|
||||
question={question}
|
||||
questionIdx={questionIdx}
|
||||
updateQuestion={updateQuestion}
|
||||
lastQuestion={lastQuestion}
|
||||
selectedLanguageCode={selectedLanguageCode}
|
||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||
isInvalid={isInvalid}
|
||||
|
||||
@@ -502,11 +502,7 @@ export const QuestionsView = ({
|
||||
|
||||
{!isCxMode && (
|
||||
<>
|
||||
<AddEndingCardButton
|
||||
localSurvey={localSurvey}
|
||||
setLocalSurvey={setLocalSurvey}
|
||||
addEndingCard={addEndingCard}
|
||||
/>
|
||||
<AddEndingCardButton localSurvey={localSurvey} addEndingCard={addEndingCard} />
|
||||
<hr />
|
||||
|
||||
<HiddenFieldsCard
|
||||
|
||||
@@ -69,9 +69,9 @@ export const SavedActionsTab = ({
|
||||
</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{actions.map((action) => (
|
||||
<div
|
||||
<button
|
||||
key={action.id}
|
||||
className="cursor-pointer rounded-md border border-slate-300 bg-white px-4 py-2 hover:bg-slate-100"
|
||||
className="flex cursor-pointer flex-col items-start rounded-md border border-slate-300 bg-white px-4 py-2 hover:bg-slate-100"
|
||||
onClick={() => handleActionClick(action)}>
|
||||
<div className="mt-1 flex items-center">
|
||||
<div className="mr-1.5 h-4 w-4 text-slate-600">
|
||||
@@ -80,7 +80,7 @@ export const SavedActionsTab = ({
|
||||
<h4 className="text-sm font-semibold text-slate-600">{action.name}</h4>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-slate-500">{action.description}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -337,7 +337,7 @@ describe("FollowUpItem", () => {
|
||||
);
|
||||
|
||||
// Find the clickable area
|
||||
const clickableArea = screen.getByText("Test Follow-up").closest("div");
|
||||
const clickableArea = screen.getByText("Test Follow-up").closest("button");
|
||||
expect(clickableArea).toBeInTheDocument();
|
||||
|
||||
// Simulate a click on the clickable area
|
||||
|
||||
@@ -113,8 +113,8 @@ export const FollowUpItem = ({
|
||||
return (
|
||||
<>
|
||||
<div className="relative cursor-pointer rounded-lg border border-slate-300 bg-white p-4 hover:bg-slate-50">
|
||||
<div
|
||||
className="flex flex-col space-y-2"
|
||||
<button
|
||||
className="flex w-full flex-col items-start space-y-2"
|
||||
onClick={() => {
|
||||
setEditFollowUpModalOpen(true);
|
||||
}}>
|
||||
@@ -144,7 +144,7 @@ export const FollowUpItem = ({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="absolute right-4 top-4 flex items-center">
|
||||
<TooltipRenderer tooltipContent={t("common.delete")}>
|
||||
|
||||
@@ -97,7 +97,7 @@ export const SurveyCard = ({
|
||||
{survey.creator ? survey.creator.name : "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute right-3 top-3.5">
|
||||
<button className="absolute right-3 top-3.5" onClick={(e) => e.stopPropagation()}>
|
||||
<SurveyDropDownMenu
|
||||
survey={survey}
|
||||
key={`surveys-${survey.id}`}
|
||||
@@ -109,7 +109,7 @@ export const SurveyCard = ({
|
||||
duplicateSurvey={duplicateSurvey}
|
||||
deleteSurvey={deleteSurvey}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -129,8 +129,7 @@ export const SurveyDropDownMenu = ({
|
||||
return (
|
||||
<div
|
||||
id={`${survey.name.toLowerCase().split(" ").join("-")}-survey-actions`}
|
||||
data-testid="survey-dropdown-menu"
|
||||
onClick={(e) => e.stopPropagation()}>
|
||||
data-testid="survey-dropdown-menu">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild disabled={disabled}>
|
||||
<div
|
||||
@@ -172,27 +171,25 @@ export const SurveyDropDownMenu = ({
|
||||
</>
|
||||
)}
|
||||
{!isSurveyCreationDeletionDisabled && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
disabled={loading}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsCopyFormOpen(true);
|
||||
}}>
|
||||
<ArrowUpFromLineIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.copy")}...
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{survey.type === "link" && survey.status !== "draft" && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<div
|
||||
<button
|
||||
className="flex w-full cursor-pointer items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
@@ -205,7 +202,7 @@ export const SurveyDropDownMenu = ({
|
||||
}}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.preview_survey")}
|
||||
</div>
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
|
||||
@@ -17,11 +17,12 @@ export const PopoverPicker = ({ color, onChange, disabled = false }: PopoverPick
|
||||
|
||||
return (
|
||||
<div className="picker relative">
|
||||
<div
|
||||
<button
|
||||
id="color-picker"
|
||||
className="h-6 w-10 cursor-pointer rounded border border-slate-200"
|
||||
style={{ backgroundColor: color, opacity: disabled ? 0.5 : 1 }}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (!disabled) {
|
||||
toggle(!isOpen);
|
||||
}
|
||||
|
||||
@@ -115,8 +115,8 @@ describe("DataTableHeader", () => {
|
||||
</table>
|
||||
);
|
||||
|
||||
// The grip vertical icon should not be present for select column
|
||||
expect(screen.queryByRole("button")).not.toBeInTheDocument();
|
||||
// The column settings button (EllipsisVerticalIcon) should not be present for select column
|
||||
expect(document.querySelector(".lucide-ellipsis-vertical")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders resize handle that calls resize handler", async () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: Dat
|
||||
)}
|
||||
|
||||
{/* Resize handle */}
|
||||
<div
|
||||
<button
|
||||
onDoubleClick={() => header.column.resetSize()}
|
||||
onMouseDown={header.getResizeHandler()}
|
||||
onTouchStart={header.getResizeHandler()}
|
||||
@@ -68,8 +68,7 @@ export const DataTableHeader = <T,>({ header, setIsTableSettingsModalOpen }: Dat
|
||||
"absolute right-0 top-0 hidden h-full w-1 cursor-col-resize bg-slate-500",
|
||||
header.column.getIsResizing() ? "bg-black" : "bg-slate-500",
|
||||
!header.column.getCanResize() ? "hidden" : "group-hover:block"
|
||||
)}
|
||||
/>
|
||||
)}></button>
|
||||
</div>
|
||||
</TableHead>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,7 @@ const defaultProps = {
|
||||
|
||||
describe("DataTableToolbar", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export const DataTableToolbar = <T,>({
|
||||
<TooltipRenderer
|
||||
tooltipContent={t("environments.contacts.contacts_table_refresh")}
|
||||
shouldRender={true}>
|
||||
<div
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (refreshContacts) {
|
||||
try {
|
||||
@@ -65,28 +65,28 @@ export const DataTableToolbar = <T,>({
|
||||
}}
|
||||
className="cursor-pointer rounded-md border bg-white hover:border-slate-400">
|
||||
<RefreshCcwIcon strokeWidth={1.5} className={cn("m-1 h-6 w-6 p-0.5")} />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipRenderer>
|
||||
) : null}
|
||||
|
||||
<TooltipRenderer tooltipContent={t("common.table_settings")} shouldRender={true}>
|
||||
<div
|
||||
<button
|
||||
onClick={() => setIsTableSettingsModalOpen(true)}
|
||||
className="cursor-pointer rounded-md border bg-white hover:border-slate-400">
|
||||
<SettingsIcon strokeWidth={1.5} className="m-1 h-6 w-6 p-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipRenderer>
|
||||
<TooltipRenderer
|
||||
tooltipContent={isExpanded ? t("common.collapse_rows") : t("common.expand_rows")}
|
||||
shouldRender={true}>
|
||||
<div
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className={cn(
|
||||
"cursor-pointer rounded-md border bg-white hover:border-slate-400",
|
||||
isExpanded && "bg-black text-white"
|
||||
)}>
|
||||
<MoveVerticalIcon strokeWidth={1.5} className="m-1 h-6 w-6 p-0.5" />
|
||||
</div>
|
||||
</button>
|
||||
</TooltipRenderer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ export const SelectedRowSettings = <T,>({
|
||||
<>
|
||||
<div className="bg-primary flex items-center gap-x-2 rounded-md p-1 px-2 text-xs text-white">
|
||||
<div className="lowercase">
|
||||
{selectedRowCount} {t(`common.${type}`)}s {t("common.selected")}
|
||||
{selectedRowCount} {t(`common.${type}s`)} {t("common.selected")}
|
||||
</div>
|
||||
<Separator />
|
||||
<Button
|
||||
|
||||
@@ -33,7 +33,7 @@ export const Uploader = ({
|
||||
return (
|
||||
<label
|
||||
htmlFor={`${id}-${name}`}
|
||||
data-testId="upload-file-label"
|
||||
data-testid="upload-file-label"
|
||||
className={cn(
|
||||
"relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 dark:border-slate-600 dark:bg-slate-700",
|
||||
uploaderClassName,
|
||||
|
||||
@@ -119,7 +119,7 @@ export const VideoSettings = ({
|
||||
|
||||
{isYoutubeLink && (
|
||||
<AdvancedOptionToggle
|
||||
data-testId="youtube-privacy-mode"
|
||||
data-testid="youtube-privacy-mode"
|
||||
htmlId="youtubePrivacyMode"
|
||||
isChecked={isYoutubePrivacyModeEnabled}
|
||||
onToggle={toggleYoutubePrivacyMode}
|
||||
|
||||
@@ -36,7 +36,7 @@ interface FileInputProps {
|
||||
interface SelectedFile {
|
||||
url: string;
|
||||
name: string;
|
||||
uploaded: Boolean;
|
||||
uploaded: boolean;
|
||||
}
|
||||
|
||||
export const FileInput = ({
|
||||
@@ -236,11 +236,14 @@ export const FileInput = ({
|
||||
className={!file.uploaded ? "opacity-50" : ""}
|
||||
/>
|
||||
{file.uploaded ? (
|
||||
<div
|
||||
<button
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(idx)}>
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(idx);
|
||||
}}>
|
||||
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
@@ -254,11 +257,14 @@ export const FileInput = ({
|
||||
<span className="font-semibold">{file.name}</span>
|
||||
</p>
|
||||
{file.uploaded ? (
|
||||
<div
|
||||
<button
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(idx)}>
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(idx);
|
||||
}}>
|
||||
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
@@ -294,11 +300,14 @@ export const FileInput = ({
|
||||
className={!selectedFiles[0].uploaded ? "opacity-50" : ""}
|
||||
/>
|
||||
{selectedFiles[0].uploaded ? (
|
||||
<div
|
||||
<button
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(0)}>
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(0);
|
||||
}}>
|
||||
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
@@ -310,11 +319,14 @@ export const FileInput = ({
|
||||
<span className="font-semibold">{selectedFiles[0].name}</span>
|
||||
</p>
|
||||
{selectedFiles[0].uploaded ? (
|
||||
<div
|
||||
<button
|
||||
className="absolute right-2 top-2 flex cursor-pointer items-center justify-center rounded-md bg-slate-100 p-1 hover:bg-slate-200 hover:bg-white/90"
|
||||
onClick={() => handleRemove(0)}>
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleRemove(0);
|
||||
}}>
|
||||
<XIcon className="h-5 text-slate-700 hover:text-slate-900" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
@@ -73,10 +73,10 @@ const SegmentDetail = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
<button
|
||||
key={segment.id}
|
||||
className={cn(
|
||||
"relative mt-1 grid h-16 cursor-pointer grid-cols-5 content-center rounded-lg hover:bg-slate-100",
|
||||
"relative mt-1 grid h-16 w-full cursor-pointer grid-cols-5 content-center rounded-lg hover:bg-slate-100",
|
||||
currentSegment.id === segment.id && "pointer-events-none bg-slate-100 opacity-60"
|
||||
)}
|
||||
onClick={async () => {
|
||||
@@ -112,7 +112,7 @@ const SegmentDetail = ({
|
||||
<div className="whitespace-wrap col-span-1 my-auto hidden text-center text-sm text-slate-500 sm:block">
|
||||
<div className="ph-no-capture text-slate-900">{formatDate(segment.createdAt)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -169,6 +169,7 @@ export const LoadSegmentModal = ({
|
||||
|
||||
{segmentsArray.map((segment) => (
|
||||
<SegmentDetail
|
||||
key={segment.id}
|
||||
segment={segment}
|
||||
setIsSegmentEditorOpen={setIsSegmentEditorOpen}
|
||||
setOpen={setOpen}
|
||||
|
||||
@@ -42,10 +42,13 @@ export const OptionsSwitch = ({
|
||||
style={highlightStyle}
|
||||
/>
|
||||
{questionTypes.map((type) => (
|
||||
<div
|
||||
<button
|
||||
key={type.value}
|
||||
data-value={type.value}
|
||||
onClick={() => !type.disabled && handleOptionChange(type.value)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
!type.disabled && handleOptionChange(type.value);
|
||||
}}
|
||||
className={`relative z-10 flex-grow rounded-md p-2 text-center transition-colors duration-200 ${
|
||||
type.disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
@@ -57,7 +60,7 @@ export const OptionsSwitch = ({
|
||||
<span className="text-sm text-slate-900">{type.label}</span>
|
||||
{type.icon && <div className="h-4 w-4 text-slate-600 hover:text-slate-800">{type.icon}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,8 +8,8 @@ interface TabOptionProps {
|
||||
|
||||
export const TabOption = ({ active, icon, onClick }: TabOptionProps) => {
|
||||
return (
|
||||
<div className={`${active ? "rounded-full bg-slate-200" : ""} cursor-pointer`} onClick={onClick}>
|
||||
<button className={`${active ? "rounded-full bg-slate-200" : ""} cursor-pointer`} onClick={onClick}>
|
||||
{icon}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -37,14 +37,14 @@ export const Tag = ({
|
||||
</div>
|
||||
|
||||
{allowDelete && (
|
||||
<span
|
||||
<button
|
||||
className="cursor-pointer text-sm"
|
||||
onClick={() => {
|
||||
if (tags && setTagsState) setTagsState(tags.filter((tag) => tag.tagId !== tagId));
|
||||
onDelete(tagId);
|
||||
}}>
|
||||
<XCircleIcon fontSize={24} className="h-4 w-4 text-slate-100 hover:text-slate-200" />
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -180,9 +180,9 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
|
||||
isEditorView>
|
||||
{!project.styling?.isLogoHidden && (
|
||||
<div className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
|
||||
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
|
||||
<ClientLogo projectLogo={project.logo} previewSurvey />
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
key={surveyFormKey}
|
||||
@@ -205,17 +205,19 @@ export const ThemeStylingPreviewSurvey = ({
|
||||
|
||||
{/* for toggling between mobile and desktop mode */}
|
||||
<div className="mt-2 flex rounded-full border-2 border-slate-300 p-1">
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`${previewType === "link" ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("link")}>
|
||||
{t("common.link_survey")}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
className={`${isAppSurvey ? "rounded-full bg-slate-200" : ""} cursor-pointer px-3 py-1 text-sm`}
|
||||
onClick={() => setPreviewType("app")}>
|
||||
{t("common.app_survey")}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -171,22 +171,25 @@ export function RankingQuestion({
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " ") {
|
||||
handleItemClick(item);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"fb-flex fb-h-12 fb-items-center fb-mb-2 fb-border fb-border-border fb-transition-all fb-text-heading focus-within:fb-border-brand hover:fb-bg-input-bg-selected focus:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-cursor-pointer focus:fb-outline-none fb-transform fb-duration-500 fb-ease-in-out",
|
||||
"fb-flex fb-h-12 fb-items-center fb-mb-2 fb-border fb-border-border fb-transition-all fb-text-heading hover:fb-bg-input-bg-selected focus-within:fb-border-brand focus-within:fb-shadow-outline focus-within:fb-bg-input-bg-selected fb-rounded-custom fb-relative fb-cursor-pointer w-full focus:outline-none",
|
||||
isSorted ? "fb-bg-input-bg-selected" : "fb-bg-input-bg"
|
||||
)}
|
||||
autoFocus={idx === 0 && autoFocusEnabled}>
|
||||
<div
|
||||
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group"
|
||||
onClick={() => {
|
||||
)}>
|
||||
<button
|
||||
autoFocus={idx === 0 && autoFocusEnabled}
|
||||
tabIndex={isCurrent ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === " ") {
|
||||
e.preventDefault();
|
||||
handleItemClick(item);
|
||||
}
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleItemClick(item);
|
||||
}}>
|
||||
}}
|
||||
type="button"
|
||||
className="fb-flex fb-gap-x-4 fb-px-4 fb-items-center fb-grow fb-h-full group text-left focus:outline-none">
|
||||
<span
|
||||
className={cn(
|
||||
"fb-w-6 fb-grow-0 fb-h-6 fb-flex fb-items-center fb-justify-center fb-rounded-full fb-text-xs fb-font-semibold fb-border-brand fb-border",
|
||||
@@ -199,13 +202,14 @@ export function RankingQuestion({
|
||||
<div className="fb-grow fb-shrink fb-font-medium fb-text-sm">
|
||||
{getLocalizedValue(item.label, languageCode)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{isSorted ? (
|
||||
<div className="fb-flex fb-flex-col fb-h-full fb-grow-0 fb-border-l fb-border-border">
|
||||
<button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMove(item.id, "up");
|
||||
}}
|
||||
className={cn(
|
||||
@@ -232,7 +236,8 @@ export function RankingQuestion({
|
||||
<button
|
||||
tabIndex={-1}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleMove(item.id, "down");
|
||||
}}
|
||||
className={cn(
|
||||
|
||||
Reference in New Issue
Block a user