feat: download selection of responses (#5488)

Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
Jakob Schott
2025-05-17 02:59:14 +02:00
committed by GitHub
parent 7678084061
commit 65b051f0eb
17 changed files with 875 additions and 812 deletions

View File

@@ -1,487 +1,494 @@
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import type { DragEndEvent } from "@dnd-kit/core";
import { act, cleanup, render, screen } from "@testing-library/react";
import { ResponseTable } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTable";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import toast from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey, TSurveyQuestion, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser, TUserLocale } from "@formbricks/types/user";
import { ResponseTable } from "./ResponseTable";
import { TUserLocale } from "@formbricks/types/user";
// Hoist variables used in mock factories
const { DndContextMock, SortableContextMock, arrayMoveMock } = vi.hoisted(() => {
const dndMock = vi.fn(({ children, onDragEnd }) => {
// Store the onDragEnd prop to allow triggering it in tests
(dndMock as any).lastOnDragEnd = onDragEnd;
return <div data-testid="dnd-context">{children}</div>;
});
const sortableMock = vi.fn(({ children }) => <>{children}</>);
const moveMock = vi.fn((array, from, to) => {
const newArray = [...array];
const [item] = newArray.splice(from, 1);
newArray.splice(to, 0, item);
return newArray;
});
return {
DndContextMock: dndMock,
SortableContextMock: sortableMock,
arrayMoveMock: moveMock,
};
});
// Mock react-hot-toast
vi.mock("react-hot-toast", () => ({
default: {
error: vi.fn(),
success: vi.fn(),
dismiss: vi.fn(),
},
}));
vi.mock("@dnd-kit/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
return {
...actual,
DndContext: DndContextMock,
useSensor: vi.fn(),
useSensors: vi.fn(),
closestCenter: vi.fn(),
};
});
// Mock components
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, onClick, ...props }: any) => (
<button onClick={onClick} data-testid="button" {...props}>
{children}
</button>
),
}));
// Mock DndContext/SortableContext
vi.mock("@dnd-kit/core", () => ({
DndContext: ({ children }: any) => <div>{children}</div>,
useSensor: vi.fn(),
useSensors: vi.fn(() => "sensors"),
closestCenter: vi.fn(),
MouseSensor: vi.fn(),
TouchSensor: vi.fn(),
KeyboardSensor: vi.fn(),
}));
vi.mock("@dnd-kit/modifiers", () => ({
restrictToHorizontalAxis: vi.fn(),
restrictToHorizontalAxis: "restrictToHorizontalAxis",
}));
vi.mock("@dnd-kit/sortable", () => ({
SortableContext: SortableContextMock,
arrayMove: arrayMoveMock,
horizontalListSortingStrategy: vi.fn(),
SortableContext: ({ children }: any) => <>{children}</>,
horizontalListSortingStrategy: "horizontalListSortingStrategy",
arrayMove: vi.fn((arr, oldIndex, newIndex) => {
const result = [...arr];
const [removed] = result.splice(oldIndex, 1);
result.splice(newIndex, 0, removed);
return result;
}),
}));
// Mock AutoAnimate
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [vi.fn()],
}));
// Mock UI components
vi.mock("@/modules/ui/components/data-table", () => ({
DataTableHeader: ({ header }: any) => <th data-testid={`header-${header.id}`}>{header.id}</th>,
DataTableSettingsModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="settings-modal">
Settings Modal <button onClick={() => setOpen(false)}>Close</button>
</div>
) : null,
DataTableToolbar: ({
table,
deleteRowsAction,
downloadRowsAction,
setIsTableSettingsModalOpen,
setIsExpanded,
isExpanded,
}: any) => (
<div data-testid="table-toolbar">
<button
data-testid="toggle-expand"
onClick={() => setIsExpanded(!isExpanded)}
aria-pressed={isExpanded}>
Toggle Expand
</button>
<button data-testid="open-settings" onClick={() => setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="delete-rows"
onClick={() => deleteRowsAction(Object.keys(table.getState().rowSelection))}>
Delete Selected
</button>
<button
data-testid="download-csv"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "csv")}>
Download CSV
</button>
<button
data-testid="download-xlsx"
onClick={() => downloadRowsAction(Object.keys(table.getState().rowSelection), "xlsx")}>
Download XLSX
</button>
</div>
),
}));
// Mock child components and hooks
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal",
() => ({
ResponseCardModal: vi.fn(({ open, setOpen, selectedResponseId }) =>
ResponseCardModal: ({ open, setOpen }: any) =>
open ? (
<div data-testid="response-card-modal">
Selected Response ID: {selectedResponseId}
<button onClick={() => setOpen(false)}>Close ResponseCardModal</button>
<div data-testid="response-modal">
Response Modal <button onClick={() => setOpen(false)}>Close</button>
</div>
) : null
),
) : null,
})
);
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell",
() => ({
ResponseTableCell: vi.fn(({ cell, row, setSelectedResponseId }) => (
<td data-testid={`cell-${cell.id}`} onClick={() => setSelectedResponseId(row.original.responseId)}>
{typeof cell.getValue === "function" ? cell.getValue() : JSON.stringify(cell.getValue())}
ResponseTableCell: ({ cell, row, setSelectedResponseId }: any) => (
<td data-testid={`cell-${cell.id}-${row.id}`} onClick={() => setSelectedResponseId(row.id)}>
Cell Content
</td>
)),
),
})
);
const mockGeneratedColumns = [
{
id: "select",
header: () => "Select",
cell: vi.fn(() => "SelectCell"),
enableSorting: false,
meta: { type: "select", questionType: null, hidden: false },
},
{
id: "createdAt",
header: () => "Created At",
cell: vi.fn(({ row }) => new Date(row.original.createdAt).toISOString()),
enableSorting: true,
meta: { type: "createdAt", questionType: null, hidden: false },
},
{
id: "q1",
header: () => "Question 1",
cell: vi.fn(({ row }) => row.original.responseData.q1),
enableSorting: true,
meta: { type: "question", questionType: "openText", hidden: false },
},
];
vi.mock(
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns",
() => ({
generateResponseTableColumns: vi.fn(() => mockGeneratedColumns),
generateResponseTableColumns: vi.fn(() => [
{ id: "select", accessorKey: "select", header: "Select" },
{ id: "createdAt", accessorKey: "createdAt", header: "Created At" },
{ id: "person", accessorKey: "person", header: "Person" },
{ id: "status", accessorKey: "status", header: "Status" },
]),
})
);
vi.mock("@/modules/ui/components/table", () => ({
Table: ({ children, ...props }: any) => <table {...props}>{children}</table>,
TableBody: ({ children, ...props }: any) => <tbody {...props}>{children}</tbody>,
TableCell: ({ children, ...props }: any) => <td {...props}>{children}</td>,
TableHeader: ({ children, ...props }: any) => <thead {...props}>{children}</thead>,
TableRow: ({ children, ...props }: any) => <tr {...props}>{children}</tr>,
}));
vi.mock("@/modules/ui/components/skeleton", () => ({
Skeleton: ({ children }: any) => <div data-testid="skeleton">{children}</div>,
}));
// Mock the actions
vi.mock("@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions", () => ({
getResponsesDownloadUrlAction: vi.fn(),
}));
vi.mock("@/modules/analysis/components/SingleResponseCard/actions", () => ({
deleteResponseAction: vi.fn(),
}));
vi.mock("@/modules/ui/components/data-table", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/ui/components/data-table")>();
return {
...actual,
DataTableToolbar: vi.fn((props) => (
<div data-testid="data-table-toolbar">
<button data-testid="toolbar-expand-toggle" onClick={() => props.setIsExpanded(!props.isExpanded)}>
Toggle Expand
</button>
<button data-testid="toolbar-open-settings" onClick={() => props.setIsTableSettingsModalOpen(true)}>
Open Settings
</button>
<button
data-testid="toolbar-delete-selected"
onClick={() => props.deleteRows(props.table.getSelectedRowModel().rows.map((r) => r.id))}>
Delete Selected
</button>
<button data-testid="toolbar-delete-single" onClick={() => props.deleteAction("single_response_id")}>
Delete Single Action
</button>
</div>
)),
DataTableHeader: vi.fn(({ header }) => (
<th
data-testid={`header-${header.id}`}
onClick={() => header.column.getToggleSortingHandler()?.(new MouseEvent("click"))}>
{typeof header.column.columnDef.header === "function"
? header.column.columnDef.header(header.getContext())
: header.column.columnDef.header}
<button
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
data-testid={`resize-${header.id}`}>
Resize
</button>
</th>
)),
DataTableSettingsModal: vi.fn(({ open, setOpen }) =>
open ? (
<div data-testid="data-table-settings-modal">
<button onClick={() => setOpen(false)}>Close Settings</button>
</div>
) : null
),
};
});
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: vi.fn(() => [vi.fn()]),
// Mock helper functions
vi.mock("@/lib/utils/helper", () => ({
getFormattedErrorMessage: vi.fn(),
}));
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: vi.fn((key) => key), // Simple pass-through mock
}),
}));
const localStorageMock = (() => {
// Mock localStorage
const mockLocalStorage = (() => {
let store: Record<string, string> = {};
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => {
store[key] = value.toString();
getItem: vi.fn((key) => store[key] || null),
setItem: vi.fn((key, value) => {
store[key] = String(value);
}),
clear: () => {
clear: vi.fn(() => {
store = {};
},
removeItem: vi.fn((key: string) => {
}),
removeItem: vi.fn((key) => {
delete store[key];
}),
};
})();
Object.defineProperty(window, "localStorage", { value: localStorageMock });
Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
const mockSurvey = {
id: "survey1",
name: "Test Survey",
type: "app",
status: "inProgress",
questions: [
{
id: "q1",
type: TSurveyQuestionTypeEnum.OpenText,
headline: { default: "Question 1" },
required: true,
} as unknown as TSurveyQuestion,
],
hiddenFields: { enabled: true, fieldIds: ["hidden1"] },
variables: [{ id: "var1", name: "Variable 1", type: "text", value: "default" }],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
welcomeCard: {
enabled: false,
headline: { default: "" },
html: { default: "" },
timeToFinish: false,
showResponseCount: false,
},
autoClose: null,
delay: 0,
autoComplete: null,
closeOnDate: null,
displayOption: "displayOnce",
recontactDays: null,
singleUse: { enabled: false, isEncrypted: true },
triggers: [],
languages: [],
styling: null,
surveyClosedMessage: null,
resultShareKey: null,
displayPercentage: null,
} as unknown as TSurvey;
// Mock Tolgee
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key: string) => key,
}),
}));
const mockResponses: TResponse[] = [
{
id: "res1",
surveyId: "survey1",
finished: true,
data: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
// Define mock data for tests
const mockProps = {
data: [
{ responseId: "resp1", createdAt: new Date().toISOString(), status: "completed", person: "Person 1" },
{ responseId: "resp2", createdAt: new Date().toISOString(), status: "completed", person: "Person 2" },
] as any[],
survey: {
id: "survey1",
createdAt: new Date(),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
{
id: "res2",
surveyId: "survey1",
finished: false,
data: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
updatedAt: new Date(),
meta: {},
singleUseId: null,
ttc: {},
tags: [],
notes: [],
variables: {},
language: "en",
contact: null,
contactAttributes: null,
},
];
const mockResponseTableData: TResponseTableData[] = [
{
responseId: "res1",
responseData: { q1: "Response 1 Text" },
createdAt: new Date("2023-01-01T10:00:00.000Z"),
status: "Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
{
responseId: "res2",
responseData: { q1: "Response 2 Text" },
createdAt: new Date("2023-01-02T10:00:00.000Z"),
status: "Not Completed",
tags: [],
notes: [],
variables: {},
verifiedEmail: "",
language: "en",
person: null,
contactAttributes: null,
},
];
const mockEnvironment = {
id: "env1",
createdAt: new Date(),
updatedAt: new Date(),
type: "development",
appSetupCompleted: false,
} as unknown as TEnvironment;
const mockUser = {
id: "user1",
name: "Test User",
email: "user@test.com",
emailVerified: new Date(),
imageUrl: "",
twoFactorEnabled: false,
identityProvider: "email",
createdAt: new Date(),
updatedAt: new Date(),
role: "project_manager",
objective: "other",
notificationSettings: { alert: {}, weeklySummary: {} },
} as unknown as TUser;
const mockEnvironmentTags: TTag[] = [
{ id: "tag1", name: "Tag 1", environmentId: "env1", createdAt: new Date(), updatedAt: new Date() },
];
const mockLocale: TUserLocale = "en-US";
const defaultProps = {
data: mockResponseTableData,
survey: mockSurvey,
responses: mockResponses,
environment: mockEnvironment,
user: mockUser,
environmentTags: mockEnvironmentTags,
name: "name",
type: "link",
environmentId: "env-1",
createdBy: null,
status: "draft",
} as TSurvey,
responses: [
{ id: "resp1", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
{ id: "resp2", surveyId: "survey1", data: {}, createdAt: new Date(), updatedAt: new Date() },
] as TResponse[],
environment: { id: "env1" } as TEnvironment,
environmentTags: [] as TTag[],
isReadOnly: false,
fetchNextPage: vi.fn(),
hasMore: true,
hasMore: false,
deleteResponses: vi.fn(),
updateResponse: vi.fn(),
isFetchingFirstPage: false,
locale: mockLocale,
locale: "en" as TUserLocale,
};
// Setup a container for React Testing Library before each test
beforeEach(() => {
const container = document.createElement("div");
container.id = "test-container";
document.body.appendChild(container);
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
// Create a mock anchor element for download tests
const mockAnchor = {
href: "",
click: vi.fn(),
style: {},
};
// Update how we mock the document methods to avoid infinite recursion
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
if (tagName === "a") return mockAnchor as any;
return originalCreateElement(tagName);
});
vi.spyOn(document.body, "appendChild").mockReturnValue(null as any);
vi.spyOn(document.body, "removeChild").mockReturnValue(null as any);
});
// Cleanup after each test
afterEach(() => {
const container = document.getElementById("test-container");
if (container) {
document.body.removeChild(container);
}
cleanup();
vi.restoreAllMocks(); // Restore mocks after each test
});
describe("ResponseTable", () => {
afterEach(() => {
cleanup();
localStorageMock.clear();
vi.clearAllMocks();
cleanup(); // Keep cleanup within describe as per instructions
});
test("renders skeleton when isFetchingFirstPage is true", () => {
render(<ResponseTable {...defaultProps} isFetchingFirstPage={true} />);
// Check for skeleton elements (implementation detail, might need adjustment)
// For now, check that data is not directly rendered
expect(screen.queryByText("Response 1 Text")).not.toBeInTheDocument();
// Check if table headers are still there
expect(screen.getByText("Created At")).toBeInTheDocument();
test("renders the table with data", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
expect(screen.getByRole("table")).toBeInTheDocument();
expect(screen.getByTestId("table-toolbar")).toBeInTheDocument();
});
test("loads settings from localStorage on mount", () => {
const savedOrder = ["q1", "createdAt", "select"];
const savedVisibility = { createdAt: false };
const savedExpanded = true;
localStorageMock.setItem(`${mockSurvey.id}-columnOrder`, JSON.stringify(savedOrder));
localStorageMock.setItem(`${mockSurvey.id}-columnVisibility`, JSON.stringify(savedVisibility));
localStorageMock.setItem(`${mockSurvey.id}-rowExpand`, JSON.stringify(savedExpanded));
render(<ResponseTable {...defaultProps} />);
// Check if generateResponseTableColumns was called with the loaded expanded state
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
savedExpanded,
false,
expect.any(Function)
);
});
test("saves settings to localStorage when they change", async () => {
const { rerender } = render(<ResponseTable {...defaultProps} />);
// Simulate column order change via DND
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
rerender(<ResponseTable {...defaultProps} />); // Rerender to reflect state change if necessary for useEffect
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"])
);
// Simulate visibility change (e.g. via settings modal - direct state change for test)
// This would typically happen via table.setColumnVisibility, which is internal to useReactTable
// For this test, we'll assume a mechanism changes columnVisibility state
// This part is hard to test without deeper mocking of useReactTable or exposing setColumnVisibility
// Simulate row expansion change
await userEvent.click(screen.getByTestId("toolbar-expand-toggle")); // Toggle to true
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
});
test("handles column drag and drop", () => {
render(<ResponseTable {...defaultProps} />);
const dragEvent: DragEndEvent = {
active: { id: "createdAt" },
over: { id: "q1" },
delta: { x: 0, y: 0 },
activators: { x: 0, y: 0 },
collisions: null,
overNode: null,
activeNode: null,
} as any;
act(() => {
(DndContextMock as any).lastOnDragEnd?.(dragEvent);
});
expect(arrayMoveMock).toHaveBeenCalledWith(expect.arrayContaining(["createdAt", "q1"]), 1, 2); // Example indices
expect(localStorageMock.setItem).toHaveBeenCalledWith(
`${mockSurvey.id}-columnOrder`,
JSON.stringify(["select", "q1", "createdAt"]) // Based on initial ['select', 'createdAt', 'q1']
);
});
test("interacts with DataTableToolbar: toggle expand, open settings, delete", async () => {
const deleteResponsesMock = vi.fn();
const deleteResponseActionMock = vi.mocked(deleteResponseAction);
render(<ResponseTable {...defaultProps} deleteResponses={deleteResponsesMock} />);
// Toggle expand
await userEvent.click(screen.getByTestId("toolbar-expand-toggle"));
expect(vi.mocked(generateResponseTableColumns)).toHaveBeenCalledWith(
mockSurvey,
true,
false,
expect.any(Function)
);
expect(localStorageMock.setItem).toHaveBeenCalledWith(`${mockSurvey.id}-rowExpand`, "true");
// Open settings
await userEvent.click(screen.getByTestId("toolbar-open-settings"));
expect(screen.getByTestId("data-table-settings-modal")).toBeInTheDocument();
await userEvent.click(screen.getByText("Close Settings"));
expect(screen.queryByTestId("data-table-settings-modal")).not.toBeInTheDocument();
// Delete selected (mock table selection)
// This requires mocking table.getSelectedRowModel().rows
// For simplicity, we assume the toolbar button calls deleteRows correctly
// The mock for DataTableToolbar calls props.deleteRows with hardcoded IDs for now.
// To test properly, we'd need to mock table.getSelectedRowModel
// For now, let's assume the mock toolbar calls it.
// await userEvent.click(screen.getByTestId("toolbar-delete-selected"));
// expect(deleteResponsesMock).toHaveBeenCalledWith(["row1_id", "row2_id"]); // From mock toolbar
// Delete single action
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(deleteResponseActionMock).toHaveBeenCalledWith({ responseId: "single_response_id" });
});
test("calls fetchNextPage when 'Load More' is clicked", async () => {
const fetchNextPageMock = vi.fn();
render(<ResponseTable {...defaultProps} fetchNextPage={fetchNextPageMock} />);
await userEvent.click(screen.getByText("common.load_more"));
expect(fetchNextPageMock).toHaveBeenCalled();
});
test("does not show 'Load More' if hasMore is false", () => {
render(<ResponseTable {...defaultProps} hasMore={false} />);
expect(screen.queryByText("common.load_more")).not.toBeInTheDocument();
});
test("shows 'No results' when data is empty", () => {
render(<ResponseTable {...defaultProps} data={[]} responses={[]} />);
test("renders no results message when data is empty", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} data={[]} responses={[]} />, { container: container! });
expect(screen.getByText("common.no_results")).toBeInTheDocument();
});
test("deleteResponse function calls deleteResponseAction", async () => {
render(<ResponseTable {...defaultProps} />);
// This function is called by DataTableToolbar's deleteAction prop
// We can trigger it via the mocked DataTableToolbar
await userEvent.click(screen.getByTestId("toolbar-delete-single"));
expect(vi.mocked(deleteResponseAction)).toHaveBeenCalledWith({ responseId: "single_response_id" });
test("renders load more button when hasMore is true", () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
expect(screen.getByText("common.load_more")).toBeInTheDocument();
});
test("calls fetchNextPage when load more button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} hasMore={true} />, { container: container! });
const loadMoreButton = screen.getByText("common.load_more");
await userEvent.click(loadMoreButton);
expect(mockProps.fetchNextPage).toHaveBeenCalledTimes(1);
});
test("opens settings modal when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const openSettingsButton = screen.getByTestId("open-settings");
await userEvent.click(openSettingsButton);
expect(screen.getByTestId("settings-modal")).toBeInTheDocument();
});
test("toggles expanded state when toolbar button is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const toggleExpandButton = screen.getByTestId("toggle-expand");
// Initially might be null, first click should set it to true
await userEvent.click(toggleExpandButton);
expect(mockLocalStorage.setItem).toHaveBeenCalledWith("survey1-rowExpand", expect.any(String));
});
test("calls downloadSelectedRows with csv format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.csv",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "csv",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.csv");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
test("calls downloadSelectedRows with xlsx format when toolbar button is clicked", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: "https://download.url/file.xlsx",
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadXlsxButton = screen.getByTestId("download-xlsx");
await userEvent.click(downloadXlsxButton);
expect(getResponsesDownloadUrlAction).toHaveBeenCalledWith({
surveyId: "survey1",
format: "xlsx",
filterCriteria: { responseIds: [] },
});
// Check if link was created and clicked
expect(document.createElement).toHaveBeenCalledWith("a");
const mockLink = document.createElement("a");
expect(mockLink.href).toBe("https://download.url/file.xlsx");
expect(document.body.appendChild).toHaveBeenCalled();
expect(mockLink.click).toHaveBeenCalled();
expect(document.body.removeChild).toHaveBeenCalled();
});
// Test response modal
test("opens and closes response modal when a cell is clicked", async () => {
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const cell = screen.getByTestId("cell-resp1_select-resp1");
await userEvent.click(cell);
expect(screen.getByTestId("response-modal")).toBeInTheDocument();
// Close the modal
const closeButton = screen.getByText("Close");
await userEvent.click(closeButton);
// Modal should be closed now
expect(screen.queryByTestId("response-modal")).not.toBeInTheDocument();
});
test("shows error toast when download action returns error", async () => {
const errorMsg = "Download failed";
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: errorMsg,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce(errorMsg);
// Reset document.createElement spy to fix the last test
vi.mocked(document.createElement).mockClear();
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows default error toast when download action returns no data", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
});
vi.mocked(getFormattedErrorMessage).mockReturnValueOnce("");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("shows error toast when download action throws exception", async () => {
vi.mocked(getResponsesDownloadUrlAction).mockRejectedValueOnce(new Error("Network error"));
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.surveys.responses.error_downloading_responses");
});
});
test("does not create download link when download action fails", async () => {
// Clear any previous calls to document.createElement
vi.mocked(document.createElement).mockClear();
vi.mocked(getResponsesDownloadUrlAction).mockResolvedValueOnce({
data: undefined,
serverError: "Download failed",
});
// Create a fresh spy for createElement for this test only
const createElementSpy = vi.spyOn(document, "createElement");
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
const downloadCsvButton = screen.getByTestId("download-csv");
await userEvent.click(downloadCsvButton);
await waitFor(() => {
expect(getResponsesDownloadUrlAction).toHaveBeenCalled();
// Check specifically for "a" element creation, not any element
expect(createElementSpy).not.toHaveBeenCalledWith("a");
});
});
test("loads saved settings from localStorage on mount", () => {
const columnOrder = ["status", "person", "createdAt", "select"];
const columnVisibility = { status: false };
const isExpanded = true;
mockLocalStorage.getItem.mockImplementation((key) => {
if (key === "survey1-columnOrder") return JSON.stringify(columnOrder);
if (key === "survey1-columnVisibility") return JSON.stringify(columnVisibility);
if (key === "survey1-rowExpand") return JSON.stringify(isExpanded);
return null;
});
const container = document.getElementById("test-container");
render(<ResponseTable {...mockProps} />, { container: container! });
// Verify localStorage calls
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnOrder");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-columnVisibility");
expect(mockLocalStorage.getItem).toHaveBeenCalledWith("survey1-rowExpand");
// The mock for generateResponseTableColumns returns this order:
// ["select", "createdAt", "person", "status"]
// Only visible columns should be rendered, in this order
const expectedHeaders = ["select", "createdAt", "person"];
const headers = screen.getAllByTestId(/^header-/);
expect(headers).toHaveLength(expectedHeaders.length);
expectedHeaders.forEach((columnId, index) => {
expect(headers[index]).toHaveAttribute("data-testid", `header-${columnId}`);
});
// Verify column visibility is applied
const statusHeader = screen.queryByTestId("header-status");
expect(statusHeader).not.toBeInTheDocument();
// Verify row expansion is applied
const toggleExpandButton = screen.getByTestId("toggle-expand");
expect(toggleExpandButton).toHaveAttribute("aria-pressed", "true");
});
});

View File

@@ -3,6 +3,7 @@
import { ResponseCardModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseCardModal";
import { ResponseTableCell } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableCell";
import { generateResponseTableColumns } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTableColumns";
import { getResponsesDownloadUrlAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/actions";
import { deleteResponseAction } from "@/modules/analysis/components/SingleResponseCard/actions";
import { Button } from "@/modules/ui/components/button";
import {
@@ -25,15 +26,16 @@ import {
import { restrictToHorizontalAxis } from "@dnd-kit/modifiers";
import { SortableContext, arrayMove, horizontalListSortingStrategy } from "@dnd-kit/sortable";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Sentry from "@sentry/nextjs";
import { VisibilityState, getCoreRowModel, useReactTable } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TResponse, TResponseTableData } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TTag } from "@formbricks/types/tags";
import { TUser } from "@formbricks/types/user";
import { TUserLocale } from "@formbricks/types/user";
import { TUser, TUserLocale } from "@formbricks/types/user";
interface ResponseTableProps {
data: TResponseTableData[];
@@ -180,6 +182,32 @@ export const ResponseTable = ({
await deleteResponseAction({ responseId });
};
// Handle downloading selected responses
const downloadSelectedRows = async (responseIds: string[], format: "csv" | "xlsx") => {
try {
const downloadResponse = await getResponsesDownloadUrlAction({
surveyId: survey.id,
format: format,
filterCriteria: { responseIds },
});
if (downloadResponse?.data) {
const link = document.createElement("a");
link.href = downloadResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
} else {
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
} catch (error) {
Sentry.captureException(error);
toast.error(t("environments.surveys.responses.error_downloading_responses"));
}
};
return (
<div>
<DndContext
@@ -193,9 +221,10 @@ export const ResponseTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
deleteRows={deleteResponses}
deleteRowsAction={deleteResponses}
type="response"
deleteAction={deleteResponse}
downloadRowsAction={downloadSelectedRows}
/>
<div className="w-fit max-w-full overflow-hidden overflow-x-auto rounded-xl border border-slate-200">
<div className="w-full overflow-x-auto">

View File

@@ -250,6 +250,7 @@ export const CustomFilter = ({ survey }: CustomFilterProps) => {
if (responsesDownloadUrlResponse?.data) {
const link = document.createElement("a");
link.href = responsesDownloadUrlResponse.data;
link.download = "";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);

View File

@@ -22,6 +22,43 @@ export const calculateTtcTotal = (ttc: TResponseTtc) => {
return result;
};
const createFilterTags = (tags: TResponseFilterCriteria["tags"]) => {
if (!tags) return [];
const filterTags: Record<string, any>[] = [];
if (tags?.applied) {
const appliedTags = tags.applied.map((name) => ({
tags: {
some: {
tag: {
name,
},
},
},
}));
filterTags.push(appliedTags);
}
if (tags?.notApplied) {
const notAppliedTags = {
tags: {
every: {
tag: {
name: {
notIn: tags.notApplied,
},
},
},
},
};
filterTags.push(notAppliedTags);
}
return filterTags.flat();
};
export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilterCriteria) => {
const whereClause: Prisma.ResponseWhereInput["AND"] = [];
@@ -49,39 +86,9 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
// For Tags
if (filterCriteria?.tags) {
const tags: Record<string, any>[] = [];
if (filterCriteria?.tags?.applied) {
const appliedTags = filterCriteria.tags.applied.map((name) => ({
tags: {
some: {
tag: {
name,
},
},
},
}));
tags.push(appliedTags);
}
if (filterCriteria?.tags?.notApplied) {
const notAppliedTags = {
tags: {
every: {
tag: {
name: {
notIn: filterCriteria.tags.notApplied,
},
},
},
},
};
tags.push(notAppliedTags);
}
const tagFilters = createFilterTags(filterCriteria.tags);
whereClause.push({
AND: tags.flat(),
AND: tagFilters,
});
}
@@ -442,6 +449,13 @@ export const buildWhereClause = (survey: TSurvey, filterCriteria?: TResponseFilt
AND: data,
});
}
// filter by explicit response IDs
if (filterCriteria?.responseIds) {
whereClause.push({
id: { in: filterCriteria.responseIds },
});
}
return { AND: whereClause };
};

View File

@@ -1667,6 +1667,7 @@
"device": "Gerät",
"device_info": "Geräteinfo",
"email": "E-Mail",
"error_downloading_responses": "Beim Herunterladen der Antworten ist ein Fehler aufgetreten",
"first_name": "Vorname",
"how_to_identify_users": "Wie man Benutzer identifiziert",
"last_name": "Nachname",
@@ -1764,6 +1765,8 @@
"quickstart_web_apps": "Schnellstart: Web-Apps",
"quickstart_web_apps_description": "Bitte folge der Schnellstartanleitung, um loszulegen:",
"results_are_public": "Ergebnisse sind öffentlich",
"selected_responses_csv": "Ausgewählte Antworten (CSV)",
"selected_responses_excel": "Ausgewählte Antworten (Excel)",
"send_preview": "Vorschau senden",
"send_to_panel": "An das Panel senden",
"setup_instructions": "Einrichtung",

View File

@@ -1667,6 +1667,7 @@
"device": "Device",
"device_info": "Device info",
"email": "Email",
"error_downloading_responses": "An error occured while downloading responses",
"first_name": "First Name",
"how_to_identify_users": "How to identify users",
"last_name": "Last Name",
@@ -1764,6 +1765,8 @@
"quickstart_web_apps": "Quickstart: Web apps",
"quickstart_web_apps_description": "Please follow the Quickstart guide to get started:",
"results_are_public": "Results are public",
"selected_responses_csv": "Selected responses (CSV)",
"selected_responses_excel": "Selected responses (Excel)",
"send_preview": "Send preview",
"send_to_panel": "Send to panel",
"setup_instructions": "Setup instructions",

View File

@@ -1667,6 +1667,7 @@
"device": "Dispositif",
"device_info": "Informations sur l'appareil",
"email": "Email",
"error_downloading_responses": "Une erreur s'est produite lors du téléchargement des réponses",
"first_name": "Prénom",
"how_to_identify_users": "Comment identifier les utilisateurs",
"last_name": "Nom de famille",
@@ -1764,6 +1765,8 @@
"quickstart_web_apps": "Démarrage rapide : Applications web",
"quickstart_web_apps_description": "Veuillez suivre le guide de démarrage rapide pour commencer :",
"results_are_public": "Les résultats sont publics.",
"selected_responses_csv": "Réponses sélectionnées (CSV)",
"selected_responses_excel": "Réponses sélectionnées (Excel)",
"send_preview": "Envoyer un aperçu",
"send_to_panel": "Envoyer au panneau",
"setup_instructions": "Instructions d'installation",

View File

@@ -1667,6 +1667,7 @@
"device": "dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
"error_downloading_responses": "Ocorreu um erro ao baixar respostas",
"first_name": "Primeiro Nome",
"how_to_identify_users": "Como identificar usuários",
"last_name": "Sobrenome",
@@ -1764,6 +1765,8 @@
"quickstart_web_apps": "Início rápido: Aplicativos web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar prévia",
"send_to_panel": "Enviar para o painel",
"setup_instructions": "Instruções de configuração",

View File

@@ -1667,6 +1667,7 @@
"device": "Dispositivo",
"device_info": "Informações do dispositivo",
"email": "Email",
"error_downloading_responses": "Ocorreu um erro ao transferir respostas",
"first_name": "Primeiro Nome",
"how_to_identify_users": "Como identificar utilizadores",
"last_name": "Apelido",
@@ -1764,6 +1765,8 @@
"quickstart_web_apps": "Início rápido: Aplicações web",
"quickstart_web_apps_description": "Por favor, siga o guia de início rápido para começar:",
"results_are_public": "Os resultados são públicos",
"selected_responses_csv": "Respostas selecionadas (CSV)",
"selected_responses_excel": "Respostas selecionadas (Excel)",
"send_preview": "Enviar pré-visualização",
"send_to_panel": "Enviar para painel",
"setup_instructions": "Instruções de configuração",

View File

@@ -1667,6 +1667,7 @@
"device": "裝置",
"device_info": "裝置資訊",
"email": "電子郵件",
"error_downloading_responses": "下載回應時發生錯誤",
"first_name": "名字",
"how_to_identify_users": "如何識別使用者",
"last_name": "姓氏",
@@ -1764,6 +1765,8 @@
"quickstart_web_apps": "快速入門Web apps",
"quickstart_web_apps_description": "請按照 Quickstart 指南開始:",
"results_are_public": "結果是公開的",
"selected_responses_csv": "選擇的回應 (CSV)",
"selected_responses_excel": "選擇的回應 (Excel)",
"send_preview": "發送預覽",
"send_to_panel": "發送到小組",
"setup_instructions": "設定說明",

View File

@@ -236,7 +236,7 @@ export const ContactsTable = ({
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
isExpanded={isExpanded ?? false}
table={table}
deleteRows={deleteContacts}
deleteRowsAction={deleteContacts}
type="contact"
deleteAction={deleteContact}
refreshContacts={refreshContacts}

View File

@@ -297,7 +297,7 @@ export const UploadContactsCSVButton = ({
<div className="sticky top-0 flex h-full flex-col rounded-lg">
<button
className={cn(
"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"
"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"
)}
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 pb-6 pt-5">
<div className="flex flex-col items-center justify-center pt-5 pb-6">
<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>

View File

@@ -4,195 +4,140 @@ import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { DataTableToolbar } from "./data-table-toolbar";
// Mock TooltipRenderer
vi.mock("@/modules/ui/components/tooltip", () => ({
TooltipRenderer: ({ children, tooltipContent }) => (
<div data-testid="tooltip-renderer" data-tooltip-content={tooltipContent}>
{children}
</div>
),
}));
// Mock SelectedRowSettings
vi.mock("./selected-row-settings", () => ({
SelectedRowSettings: vi.fn(() => <div data-testid="selected-row-settings"></div>),
}));
// Mock useTranslate
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({
t: (key) => key,
}),
}));
// Mock toast
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
}));
// Mock lucide-react icons
vi.mock("lucide-react", async () => {
const actual = await vi.importActual("lucide-react");
return {
...actual,
RefreshCcwIcon: vi.fn((props) => <div data-testid="refresh-ccw-icon" {...props} />),
SettingsIcon: vi.fn((props) => <div data-testid="settings-icon" {...props} />),
MoveVerticalIcon: vi.fn((props) => <div data-testid="move-vertical-icon" {...props} />),
};
});
const mockTable = {
getFilteredSelectedRowModel: vi.fn(() => ({ rows: [] })),
} as any;
const mockDeleteRowsAction = vi.fn();
const mockDeleteAction = vi.fn();
const mockDownloadRowsAction = vi.fn();
const mockRefreshContacts = vi.fn();
const mockSetIsExpanded = vi.fn();
const mockSetIsTableSettingsModalOpen = vi.fn();
const defaultProps = {
setIsExpanded: mockSetIsExpanded,
setIsTableSettingsModalOpen: mockSetIsTableSettingsModalOpen,
isExpanded: false,
table: mockTable,
deleteRowsAction: mockDeleteRowsAction,
type: "response" as "response" | "contact",
deleteAction: mockDeleteAction,
downloadRowAction: mockDownloadRowsAction,
refreshContacts: mockRefreshContacts,
};
describe("DataTableToolbar", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
test("renders selection settings when rows are selected", () => {
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [{ id: "row1" }, { id: "row2" }],
}),
};
render(
<DataTableToolbar
setIsTableSettingsModalOpen={vi.fn()}
setIsExpanded={vi.fn()}
isExpanded={false}
table={mockTable as any}
deleteRows={vi.fn()}
type="response"
deleteAction={vi.fn()}
/>
);
// Check for the number of selected items instead of translation keys
const selectionInfo = screen.getByText(/2/);
expect(selectionInfo).toBeInTheDocument();
// Look for the exact text that appears in the component (which is the translation key)
expect(screen.getByText("common.select_all")).toBeInTheDocument();
expect(screen.getByText("common.clear_selection")).toBeInTheDocument();
test("renders correctly with no selected rows", () => {
render(<DataTableToolbar {...defaultProps} />);
expect(screen.queryByTestId("selected-row-settings")).not.toBeInTheDocument();
expect(screen.getByTestId("settings-icon")).toBeInTheDocument();
expect(screen.getByTestId("move-vertical-icon")).toBeInTheDocument();
expect(screen.queryByTestId("refresh-ccw-icon")).not.toBeInTheDocument();
});
test("renders settings and expand buttons", () => {
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [],
}),
};
render(
<DataTableToolbar
setIsTableSettingsModalOpen={vi.fn()}
setIsExpanded={vi.fn()}
isExpanded={false}
table={mockTable as any}
deleteRows={vi.fn()}
type="response"
deleteAction={vi.fn()}
/>
);
// Look for SVG elements by their class names instead of role
const settingsIcon = document.querySelector(".lucide-settings");
const expandIcon = document.querySelector(".lucide-move-vertical");
expect(settingsIcon).toBeInTheDocument();
expect(expandIcon).toBeInTheDocument();
test("renders SelectedRowSettings when rows are selected", () => {
const tableWithSelectedRows = {
getFilteredSelectedRowModel: vi.fn(() => ({ rows: [{ id: "1" }] })),
} as any;
render(<DataTableToolbar {...defaultProps} table={tableWithSelectedRows} />);
expect(screen.getByTestId("selected-row-settings")).toBeInTheDocument();
});
test("calls setIsTableSettingsModalOpen when settings button is clicked", async () => {
const user = userEvent.setup();
const setIsTableSettingsModalOpen = vi.fn();
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [],
}),
};
render(
<DataTableToolbar
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
setIsExpanded={vi.fn()}
isExpanded={false}
table={mockTable as any}
deleteRows={vi.fn()}
type="response"
deleteAction={vi.fn()}
/>
test("renders refresh icon for contact type and calls refreshContacts on click", async () => {
mockRefreshContacts.mockResolvedValueOnce(undefined);
render(<DataTableToolbar {...defaultProps} type="contact" />);
const refreshIconContainer = screen.getByTestId("refresh-ccw-icon").parentElement;
expect(refreshIconContainer).toBeInTheDocument();
await userEvent.click(refreshIconContainer!);
expect(mockRefreshContacts).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.success)).toHaveBeenCalledWith(
"environments.contacts.contacts_table_refresh_success"
);
// Find the settings button by class and click it
const settingsIcon = document.querySelector(".lucide-settings");
const settingsButton = settingsIcon?.closest("div");
expect(settingsButton).toBeInTheDocument();
if (settingsButton) {
await user.click(settingsButton);
expect(setIsTableSettingsModalOpen).toHaveBeenCalledWith(true);
}
});
test("calls setIsExpanded when expand button is clicked", async () => {
const user = userEvent.setup();
const setIsExpanded = vi.fn();
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [],
}),
};
render(
<DataTableToolbar
setIsTableSettingsModalOpen={vi.fn()}
setIsExpanded={setIsExpanded}
isExpanded={false}
table={mockTable as any}
deleteRows={vi.fn()}
type="response"
deleteAction={vi.fn()}
/>
);
// Find the expand button by class and click it
const expandIcon = document.querySelector(".lucide-move-vertical");
const expandButton = expandIcon?.closest("div");
expect(expandButton).toBeInTheDocument();
if (expandButton) {
await user.click(expandButton);
expect(setIsExpanded).toHaveBeenCalledWith(true);
}
test("handles refreshContacts failure", async () => {
mockRefreshContacts.mockRejectedValueOnce(new Error("Refresh failed"));
render(<DataTableToolbar {...defaultProps} type="contact" />);
const refreshIconContainer = screen.getByTestId("refresh-ccw-icon").parentElement;
await userEvent.click(refreshIconContainer!);
expect(mockRefreshContacts).toHaveBeenCalledTimes(1);
expect(vi.mocked(toast.error)).toHaveBeenCalledWith("environments.contacts.contacts_table_refresh_error");
});
test("shows refresh button and calls refreshContacts when type is contact", async () => {
const user = userEvent.setup();
const refreshContacts = vi.fn().mockResolvedValue(undefined);
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [],
}),
};
render(
<DataTableToolbar
setIsTableSettingsModalOpen={vi.fn()}
setIsExpanded={vi.fn()}
isExpanded={false}
table={mockTable as any}
deleteRows={vi.fn()}
type="contact"
deleteAction={vi.fn()}
refreshContacts={refreshContacts}
/>
);
// Find the refresh button by class and click it
const refreshIcon = document.querySelector(".lucide-refresh-ccw");
const refreshButton = refreshIcon?.closest("div");
expect(refreshButton).toBeInTheDocument();
if (refreshButton) {
await user.click(refreshButton);
expect(refreshContacts).toHaveBeenCalled();
expect(toast.success).toHaveBeenCalledWith("environments.contacts.contacts_table_refresh_success");
}
test("does not render refresh icon for response type", () => {
render(<DataTableToolbar {...defaultProps} type="response" />);
expect(screen.queryByTestId("refresh-ccw-icon")).not.toBeInTheDocument();
});
test("shows error toast when refreshContacts fails", async () => {
const user = userEvent.setup();
const refreshContacts = vi.fn().mockRejectedValue(new Error("Failed to refresh"));
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [],
}),
};
test("calls setIsTableSettingsModalOpen when settings icon is clicked", async () => {
render(<DataTableToolbar {...defaultProps} />);
const settingsIconContainer = screen.getByTestId("settings-icon").parentElement;
await userEvent.click(settingsIconContainer!);
expect(mockSetIsTableSettingsModalOpen).toHaveBeenCalledWith(true);
});
render(
<DataTableToolbar
setIsTableSettingsModalOpen={vi.fn()}
setIsExpanded={vi.fn()}
isExpanded={false}
table={mockTable as any}
deleteRows={vi.fn()}
type="contact"
deleteAction={vi.fn()}
refreshContacts={refreshContacts}
/>
);
test("calls setIsExpanded when move vertical icon is clicked (isExpanded false)", async () => {
render(<DataTableToolbar {...defaultProps} isExpanded={false} />);
const moveIconContainer = screen.getByTestId("move-vertical-icon").parentElement;
const tooltip = moveIconContainer?.closest('[data-testid="tooltip-renderer"]');
expect(tooltip).toHaveAttribute("data-tooltip-content", "common.expand_rows");
await userEvent.click(moveIconContainer!);
expect(mockSetIsExpanded).toHaveBeenCalledWith(true);
});
// Find the refresh button by class and click it
const refreshIcon = document.querySelector(".lucide-refresh-ccw");
const refreshButton = refreshIcon?.closest("div");
expect(refreshButton).toBeInTheDocument();
if (refreshButton) {
await user.click(refreshButton);
expect(refreshContacts).toHaveBeenCalled();
expect(toast.error).toHaveBeenCalledWith("environments.contacts.contacts_table_refresh_error");
}
test("calls setIsExpanded when move vertical icon is clicked (isExpanded true)", async () => {
render(<DataTableToolbar {...defaultProps} isExpanded={true} />);
const moveIconContainer = screen.getByTestId("move-vertical-icon").parentElement;
const tooltip = moveIconContainer?.closest('[data-testid="tooltip-renderer"]');
expect(tooltip).toHaveAttribute("data-tooltip-content", "common.collapse_rows");
await userEvent.click(moveIconContainer!);
expect(mockSetIsExpanded).toHaveBeenCalledWith(false);
expect(moveIconContainer).toHaveClass("bg-black text-white");
});
});

View File

@@ -13,9 +13,10 @@ interface DataTableToolbarProps<T> {
setIsExpanded: (isExpanded: boolean) => void;
isExpanded: boolean;
table: Table<T>;
deleteRows: (rowIds: string[]) => void;
deleteRowsAction: (rowIds: string[]) => void;
type: "response" | "contact";
deleteAction: (id: string) => Promise<void>;
downloadRowsAction?: (rowIds: string[], format: string) => void;
refreshContacts?: () => Promise<void>;
}
@@ -24,9 +25,10 @@ export const DataTableToolbar = <T,>({
setIsTableSettingsModalOpen,
isExpanded,
table,
deleteRows,
deleteRowsAction,
type,
deleteAction,
downloadRowsAction,
refreshContacts,
}: DataTableToolbarProps<T>) => {
const { t } = useTranslate();
@@ -34,7 +36,13 @@ export const DataTableToolbar = <T,>({
return (
<div className="sticky top-12 z-30 my-2 flex w-full items-center justify-between bg-slate-50 py-2">
{table.getFilteredSelectedRowModel().rows.length > 0 ? (
<SelectedRowSettings table={table} deleteRows={deleteRows} type={type} deleteAction={deleteAction} />
<SelectedRowSettings
table={table}
deleteRowsAction={deleteRowsAction}
type={type}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
/>
) : (
<div></div>
)}

View File

@@ -1,192 +1,187 @@
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import toast from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { toast } from "react-hot-toast";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { SelectedRowSettings } from "./selected-row-settings";
// Mock the toast functions directly since they're causing issues
vi.mock("react-hot-toast", () => ({
default: {
success: vi.fn(),
error: vi.fn(),
},
// Mock translation
vi.mock("@tolgee/react", () => ({
useTranslate: () => ({ t: (key: string) => key }),
}));
// Instead of mocking @radix-ui/react-dialog, we'll test the component's behavior
// by checking if the appropriate actions are performed after clicking the buttons
// Mock DeleteDialog to reveal confirm button when open
vi.mock("@/modules/ui/components/delete-dialog", () => ({
DeleteDialog: ({ open, onDelete }: any) =>
open ? <button onClick={() => onDelete()}>Confirm Delete</button> : null,
}));
// Mock dropdown-menu components to render their children
vi.mock("@/modules/ui/components/dropdown-menu", () => ({
DropdownMenu: ({ children }: any) => <>{children}</>,
DropdownMenuTrigger: ({ children }: any) => <>{children}</>,
DropdownMenuContent: ({ children }: any) => <>{children}</>,
DropdownMenuItem: ({ children, onClick }: any) => <button onClick={onClick}>{children}</button>,
}));
// Mock Button
vi.mock("@/modules/ui/components/button", () => ({
Button: ({ children, ...props }: any) => <button {...props}>{children}</button>,
}));
describe("SelectedRowSettings", () => {
const rows = [{ id: "r1" }, { id: "r2" }];
let table: any;
let deleteRowsAction: ReturnType<typeof vi.fn>;
let deleteAction: ReturnType<typeof vi.fn>;
let downloadRowsAction: ReturnType<typeof vi.fn>;
beforeEach(() => {
table = {
getFilteredSelectedRowModel: () => ({ rows }),
toggleAllPageRowsSelected: vi.fn(),
};
deleteRowsAction = vi.fn();
deleteAction = vi.fn(() => Promise.resolve());
downloadRowsAction = vi.fn();
// Reset all toast mocks before each test
vi.mocked(toast.error).mockClear();
vi.mocked(toast.success).mockClear();
});
afterEach(() => {
vi.resetAllMocks();
vi.clearAllMocks();
cleanup();
});
test("renders correct number of selected rows for responses", () => {
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [{ id: "row1" }, { id: "row2" }],
}),
toggleAllPageRowsSelected: vi.fn(),
};
test("renders selected count and handles select all/clear selection", () => {
render(
<SelectedRowSettings
table={mockTable as any}
deleteRows={vi.fn()}
type="response"
deleteAction={vi.fn()}
/>
);
// We need to look for a text node that contains "2" but might have other text around it
const selectionText = screen.getByText((content) => content.includes("2"));
expect(selectionText).toBeInTheDocument();
// Check that we have the correct number of common text items
expect(screen.getByText("common.select_all")).toBeInTheDocument();
expect(screen.getByText("common.clear_selection")).toBeInTheDocument();
});
test("renders correct number of selected rows for contacts", () => {
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [{ id: "contact1" }, { id: "contact2" }, { id: "contact3" }],
}),
toggleAllPageRowsSelected: vi.fn(),
};
render(
<SelectedRowSettings
table={mockTable as any}
deleteRows={vi.fn()}
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
deleteAction={vi.fn()}
/>
);
expect(screen.getByText("2 common.contacts common.selected")).toBeInTheDocument();
// We need to look for a text node that contains "3" but might have other text around it
const selectionText = screen.getByText((content) => content.includes("3"));
expect(selectionText).toBeInTheDocument();
fireEvent.click(screen.getByText("common.select_all"));
expect(table.toggleAllPageRowsSelected).toHaveBeenCalledWith(true);
// Check that the text contains contacts (using a function matcher)
const textWithContacts = screen.getByText((content) => content.includes("common.contacts"));
expect(textWithContacts).toBeInTheDocument();
fireEvent.click(screen.getByText("common.clear_selection"));
expect(table.toggleAllPageRowsSelected).toHaveBeenCalledWith(false);
});
test("select all option calls toggleAllPageRowsSelected with true", async () => {
const user = userEvent.setup();
const toggleAllPageRowsSelectedMock = vi.fn();
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [{ id: "row1" }],
}),
toggleAllPageRowsSelected: toggleAllPageRowsSelectedMock,
};
test("does not render download when downloadRows prop is undefined", () => {
render(
<SelectedRowSettings
table={mockTable as any}
deleteRows={vi.fn()}
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
type="response"
deleteAction={vi.fn()}
/>
);
await user.click(screen.getByText("common.select_all"));
expect(toggleAllPageRowsSelectedMock).toHaveBeenCalledWith(true);
expect(screen.queryByText("common.download")).toBeNull();
});
test("clear selection option calls toggleAllPageRowsSelected with false", async () => {
const user = userEvent.setup();
const toggleAllPageRowsSelectedMock = vi.fn();
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [{ id: "row1" }],
}),
toggleAllPageRowsSelected: toggleAllPageRowsSelectedMock,
};
test("invokes downloadRows with correct formats", () => {
render(
<SelectedRowSettings
table={mockTable as any}
deleteRows={vi.fn()}
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
deleteAction={vi.fn()}
/>
);
fireEvent.click(screen.getByText("common.download"));
fireEvent.click(screen.getByText("environments.surveys.summary.selected_responses_csv"));
expect(downloadRowsAction).toHaveBeenCalledWith(["r1", "r2"], "csv");
await user.click(screen.getByText("common.clear_selection"));
expect(toggleAllPageRowsSelectedMock).toHaveBeenCalledWith(false);
fireEvent.click(screen.getByText("common.download"));
fireEvent.click(screen.getByText("environments.surveys.summary.selected_responses_excel"));
expect(downloadRowsAction).toHaveBeenCalledWith(["r1", "r2"], "xlsx");
});
// For the tests that involve the modal dialog, we'll test the underlying functionality
// directly by mocking the deleteAction and deleteRows functions
test("deleteAction is called with the row ID when deleting", async () => {
const deleteActionMock = vi.fn().mockResolvedValue(undefined);
const deleteRowsMock = vi.fn();
// Create a spy for the deleteRows function
const mockTable = {
getFilteredSelectedRowModel: vi.fn().mockReturnValue({
rows: [{ id: "test-id-123" }],
}),
toggleAllPageRowsSelected: vi.fn(),
};
const { rerender } = render(
test("deletes rows successfully and shows success toast for contact", async () => {
deleteAction = vi.fn(() => Promise.resolve());
render(
<SelectedRowSettings
table={mockTable as any}
deleteRows={deleteRowsMock}
type="response"
deleteAction={deleteActionMock}
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
/>
);
// Test that the component renders the trash icon button
const trashIcon = document.querySelector(".lucide-trash2");
expect(trashIcon).toBeInTheDocument();
// Since we can't easily test the dialog interaction without mocking a lot of components,
// we can test the core functionality by calling the handlers directly
// We know that the deleteAction is called with the row ID
await deleteActionMock("test-id-123");
expect(deleteActionMock).toHaveBeenCalledWith("test-id-123");
// We know that deleteRows is called with an array of row IDs
deleteRowsMock(["test-id-123"]);
expect(deleteRowsMock).toHaveBeenCalledWith(["test-id-123"]);
// open delete dialog
fireEvent.click(screen.getAllByText("common.delete")[0]);
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() => {
expect(deleteAction).toHaveBeenCalledTimes(2);
expect(deleteRowsAction).toHaveBeenCalledWith(["r1", "r2"]);
expect(toast.success).toHaveBeenCalledWith("common.table_items_deleted_successfully");
});
});
test("toast.success is called on successful deletion", async () => {
const deleteActionMock = vi.fn().mockResolvedValue(undefined);
// We can test the toast directly
await deleteActionMock();
// In the component, after the deleteAction succeeds, it should call toast.success
toast.success("common.table_items_deleted_successfully");
// Verify that toast.success was called with the right message
expect(toast.success).toHaveBeenCalledWith("common.table_items_deleted_successfully");
test("handles delete error and shows error toast for response", async () => {
deleteAction = vi.fn(() => Promise.reject(new Error("fail delete")));
render(
<SelectedRowSettings
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
/>
);
// open delete menu (trigger button)
const deleteTriggers = screen.getAllByText("common.delete");
fireEvent.click(deleteTriggers[0]);
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("fail delete");
});
});
test("toast.error is called on deletion error", async () => {
const errorMessage = "Failed to delete";
// We can test the error path directly
toast.error(errorMessage);
// Verify that toast.error was called with the right message
expect(toast.error).toHaveBeenCalledWith(errorMessage);
test("deletes rows successfully and shows success toast for response", async () => {
deleteAction = vi.fn(() => Promise.resolve());
render(
<SelectedRowSettings
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="response"
/>
);
// open delete dialog
fireEvent.click(screen.getAllByText("common.delete")[0]);
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() => {
expect(deleteAction).toHaveBeenCalledTimes(2);
expect(deleteRowsAction).toHaveBeenCalledWith(["r1", "r2"]);
expect(toast.success).toHaveBeenCalledWith("common.table_items_deleted_successfully");
});
});
test("toast.error is called with generic message on unknown error", async () => {
// We can test the unknown error path directly
toast.error("common.an_unknown_error_occurred_while_deleting_table_items");
// Verify that toast.error was called with the generic message
expect(toast.error).toHaveBeenCalledWith("common.an_unknown_error_occurred_while_deleting_table_items");
test("handles delete error for non-Error and shows generic error toast", async () => {
deleteAction = vi.fn(() => Promise.reject("fail nonerror")); // Changed from Error to string
render(
<SelectedRowSettings
table={table}
deleteRowsAction={deleteRowsAction}
deleteAction={deleteAction}
downloadRowsAction={downloadRowsAction}
type="contact"
/>
);
// open delete menu (trigger button)
const deleteTriggers = screen.getAllByText("common.delete");
fireEvent.click(deleteTriggers[0]);
fireEvent.click(screen.getByText("Confirm Delete"));
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith("common.an_unknown_error_occurred_while_deleting_table_items");
});
});
});

View File

@@ -1,25 +1,34 @@
"use client";
import { capitalizeFirstLetter } from "@/lib/utils/strings";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/modules/ui/components/dropdown-menu";
import { Table } from "@tanstack/react-table";
import { useTranslate } from "@tolgee/react";
import { Trash2Icon } from "lucide-react";
import { ArrowDownToLineIcon, Trash2Icon } from "lucide-react";
import { useCallback, useState } from "react";
import { toast } from "react-hot-toast";
interface SelectedRowSettingsProps<T> {
table: Table<T>;
deleteRows: (rowId: string[]) => void;
deleteRowsAction: (rowId: string[]) => void;
type: "response" | "contact";
deleteAction: (id: string) => Promise<void>;
downloadRowsAction?: (rowIds: string[], format: string) => void;
}
export const SelectedRowSettings = <T,>({
table,
deleteRows,
deleteRowsAction,
type,
deleteAction,
downloadRowsAction,
}: SelectedRowSettingsProps<T>) => {
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -40,13 +49,11 @@ export const SelectedRowSettings = <T,>({
setIsDeleting(true);
const rowsToBeDeleted = table.getFilteredSelectedRowModel().rows.map((row) => row.id);
if (type === "response") {
await Promise.all(rowsToBeDeleted.map((responseId) => deleteAction(responseId)));
} else if (type === "contact") {
await Promise.all(rowsToBeDeleted.map((responseId) => deleteAction(responseId)));
if (type === "response" || type === "contact") {
await Promise.all(rowsToBeDeleted.map((rowId) => deleteAction(rowId)));
}
deleteRows(rowsToBeDeleted);
deleteRowsAction(rowsToBeDeleted);
toast.success(t("common.table_items_deleted_successfully", { type: capitalizeFirstLetter(type) }));
} catch (error) {
if (error instanceof Error) {
@@ -64,34 +71,72 @@ export const SelectedRowSettings = <T,>({
}
};
// Handle download selected rows
const handleDownloadSelectedRows = async (format: string) => {
const rowsToDownload = table.getFilteredSelectedRowModel().rows.map((row) => row.id);
if (downloadRowsAction && rowsToDownload.length > 0) {
downloadRowsAction(rowsToDownload, format);
}
};
// Helper component for the separator
const Separator = () => <div>|</div>;
// Helper component for selectable options
const SelectableOption = ({ label, onClick }: { label: string; onClick: () => void }) => (
<div className="cursor-pointer rounded-md p-1 hover:bg-slate-500" onClick={onClick}>
{label}
</div>
);
return (
<div className="flex items-center gap-x-2 rounded-md bg-slate-900 p-1 px-2 text-xs text-white">
<div className="lowercase">
{selectedRowCount} {type === "response" ? t("common.responses") : t("common.contacts")}
{t("common.selected")}
</div>
<Separator />
<SelectableOption label={t("common.select_all")} onClick={() => handleToggleAllRowsSelection(true)} />
<Separator />
<SelectableOption
label={t("common.clear_selection")}
onClick={() => handleToggleAllRowsSelection(false)}
/>
<Separator />
<div
className="cursor-pointer rounded-md bg-slate-500 p-1 hover:bg-slate-600"
onClick={() => setIsDeleteDialogOpen(true)}>
<Trash2Icon strokeWidth={1.5} className="h-4 w-4" />
<>
<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")}
</div>
<Separator />
<Button
variant="outline"
size="sm"
className="h-6 border-none px-2"
onClick={() => handleToggleAllRowsSelection(true)}>
{t("common.select_all")}
</Button>
<Button
variant="outline"
size="sm"
className="h-6 border-none px-2"
onClick={() => handleToggleAllRowsSelection(false)}>
{t("common.clear_selection")}
</Button>
<Separator />
{downloadRowsAction && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-6 gap-1 border-none px-2">
{t("common.download")}
<ArrowDownToLineIcon />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => {
handleDownloadSelectedRows("csv");
}}>
<p className="text-slate-700">{t("environments.surveys.summary.selected_responses_csv")}</p>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
handleDownloadSelectedRows("xlsx");
}}>
<p>{t("environments.surveys.summary.selected_responses_excel")}</p>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
<Button
variant="secondary"
size="sm"
className="h-6 gap-1 px-2"
onClick={() => setIsDeleteDialogOpen(true)}>
{t("common.delete")}
<Trash2Icon />
</Button>
</div>
<DeleteDialog
open={isDeleteDialogOpen}
@@ -100,6 +145,6 @@ export const SelectedRowSettings = <T,>({
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
</>
);
};

View File

@@ -155,6 +155,7 @@ const ZResponseFilterCriteriaFilledOut = z.object({
export const ZResponseFilterCriteria = z.object({
finished: z.boolean().optional(),
responseIds: z.array(ZId).optional(),
createdAt: z
.object({
min: z.date().optional(),