mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-06 05:40:02 -06:00
feat: download selection of responses (#5488)
Co-authored-by: Johannes <johannes@formbricks.com>
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "設定說明",
|
||||
|
||||
@@ -236,7 +236,7 @@ export const ContactsTable = ({
|
||||
setIsTableSettingsModalOpen={setIsTableSettingsModalOpen}
|
||||
isExpanded={isExpanded ?? false}
|
||||
table={table}
|
||||
deleteRows={deleteContacts}
|
||||
deleteRowsAction={deleteContacts}
|
||||
type="contact"
|
||||
deleteAction={deleteContact}
|
||||
refreshContacts={refreshContacts}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user