mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-21 13:40:31 -06:00
Compare commits
5 Commits
fix/emove-
...
v3.8.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40fa7a69c0 | ||
|
|
5eca30e513 | ||
|
|
4b78493782 | ||
|
|
2ce44b734f | ||
|
|
85d8f8c3ae |
26
.github/copilot-instructions.md
vendored
Normal file
26
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Testing Instructions
|
||||
|
||||
When generating test files inside the "/app/web" path, follow these rules:
|
||||
|
||||
- Use vitest
|
||||
- Ensure 100% code coverage
|
||||
- Add as few comments as possible
|
||||
- The test file should be located in the same folder as the original file
|
||||
- Use the `test` function instead of `it`
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
- Add this code inside the "describe" block and before any test:
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"github.copilot.chat.codeGeneration.instructions": [
|
||||
{
|
||||
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
|
||||
}
|
||||
],
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: ({ open, setOpenWithStates }) =>
|
||||
open ? (
|
||||
<div data-testid="add-modal">
|
||||
<button onClick={() => setOpenWithStates(false)}>close</button>
|
||||
</div>
|
||||
) : null,
|
||||
})
|
||||
);
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
environmentId: "env1",
|
||||
setIsConnected: vi.fn(),
|
||||
surveys: [],
|
||||
airtableArray: [],
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("open add modal", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/link_new_table/));
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("list integrations and open edit modal", async () => {
|
||||
const item = {
|
||||
baseId: "b",
|
||||
tableId: "t",
|
||||
surveyId: "s",
|
||||
surveyName: "S",
|
||||
tableName: "T",
|
||||
questions: "Q",
|
||||
questionIds: ["x"],
|
||||
createdAt: new Date(),
|
||||
includeVariables: false,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: false,
|
||||
};
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
const { toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
const { toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
{integrationData.length ? (
|
||||
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header, idx) => (
|
||||
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{integrationData.map((data, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
setDefaultValues({
|
||||
base: data.baseId,
|
||||
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
locale: "en-US" as const,
|
||||
} as const;
|
||||
|
||||
describe("ManageIntegration (Google Sheets)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("click link new sheet", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/link_new_sheet/));
|
||||
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("list integrations and open edit", async () => {
|
||||
const item = {
|
||||
spreadsheetId: "sid",
|
||||
spreadsheetName: "SheetName",
|
||||
surveyId: "s1",
|
||||
surveyName: "Survey1",
|
||||
questionIds: ["q1"],
|
||||
questions: "Q",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [item] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Survey1")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Survey1"));
|
||||
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
|
||||
...item,
|
||||
index: 0,
|
||||
});
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -36,11 +36,10 @@ export const ManageIntegration = ({
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const integrationArray = googleSheetIntegration
|
||||
? googleSheetIntegration.config.data
|
||||
? googleSheetIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
|
||||
if (googleSheetIntegration?.config.data) {
|
||||
integrationArray = googleSheetIntegration.config.data;
|
||||
}
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
@@ -112,9 +111,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -124,7 +123,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfig,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionCredential,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
environment: {} as any,
|
||||
locale: "en-US" as const,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
handleNotionAuthorization: vi.fn(),
|
||||
};
|
||||
|
||||
test("shows empty state when no databases", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: {
|
||||
data: [] as TIntegrationNotionConfigData[],
|
||||
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||
} as TIntegrationNotionConfig,
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders list and handles clicks", async () => {
|
||||
const data = [
|
||||
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
|
||||
] as unknown as TIntegrationNotionConfigData[];
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
|
||||
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("update and link new buttons invoke handlers", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: {
|
||||
data: [],
|
||||
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||
} as TIntegrationNotionConfig,
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
|
||||
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
|
||||
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,11 +39,11 @@ export const ManageIntegration = ({
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const integrationArray = notionIntegration
|
||||
? notionIntegration.config.data
|
||||
? notionIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
|
||||
let integrationArray: TIntegrationNotionConfigData[] = [];
|
||||
if (notionIntegration?.config.data) {
|
||||
integrationArray = notionIntegration.config.data;
|
||||
}
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
refreshChannels: vi.fn(),
|
||||
handleSlackAuthorization: vi.fn(),
|
||||
showReconnectButton: false,
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
describe("ManageIntegration (Slack)", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("link channel triggers handlers", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/link_channel/));
|
||||
expect(baseProps.refreshChannels).toHaveBeenCalled();
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("show reconnect button and triggers authorization", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
showReconnectButton={true}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "Team" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
|
||||
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("list integrations and open edit", async () => {
|
||||
const item = {
|
||||
surveyName: "S",
|
||||
channelName: "C",
|
||||
questions: "Q",
|
||||
createdAt: new Date().toISOString(),
|
||||
surveyId: "s",
|
||||
channelId: "c",
|
||||
} as unknown as TIntegrationSlackConfigData;
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [item], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { T } from "@tolgee/react";
|
||||
import { T, useTranslate } from "@tolgee/react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -43,11 +42,10 @@ export const ManageIntegration = ({
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const integrationArray = slackIntegration
|
||||
? slackIntegration.config.data
|
||||
? slackIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
let integrationArray: TIntegrationSlackConfigData[] = [];
|
||||
if (slackIntegration?.config.data) {
|
||||
integrationArray = slackIntegration.config.data;
|
||||
}
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
@@ -129,9 +127,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.surveyName}-${data.channelName}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -141,7 +139,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Cell, Row } from "@tanstack/react-table";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { ResponseTableCell } from "./ResponseTableCell";
|
||||
|
||||
const makeCell = (
|
||||
id: string,
|
||||
size = 100,
|
||||
first = false,
|
||||
last = false,
|
||||
content = "CellContent"
|
||||
): Cell<TResponseTableData, unknown> =>
|
||||
({
|
||||
column: {
|
||||
id,
|
||||
getSize: () => size,
|
||||
getIsFirstColumn: () => first,
|
||||
getIsLastColumn: () => last,
|
||||
getStart: () => 0,
|
||||
columnDef: { cell: () => content },
|
||||
},
|
||||
id,
|
||||
getContext: () => ({}),
|
||||
}) as unknown as Cell<TResponseTableData, unknown>;
|
||||
|
||||
const makeRow = (id: string, selected = false): Row<TResponseTableData> =>
|
||||
({ id, getIsSelected: () => selected }) as unknown as Row<TResponseTableData>;
|
||||
|
||||
describe("ResponseTableCell", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders cell content", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("CellContent")).toBeDefined();
|
||||
});
|
||||
|
||||
test("calls setSelectedResponseId on cell click when not select column", async () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r1" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("CellContent"));
|
||||
expect(setSel).toHaveBeenCalledWith("r1");
|
||||
});
|
||||
|
||||
test("does not call setSelectedResponseId on select column click", async () => {
|
||||
const cell = makeCell("select");
|
||||
const row = makeRow("r1");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r1" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("CellContent"));
|
||||
expect(setSel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders maximize icon for createdAt column and handles click", async () => {
|
||||
const cell = makeCell("createdAt", 120, false, false);
|
||||
const row = makeRow("r2");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r2" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /expand response/i });
|
||||
expect(btn).toBeDefined();
|
||||
await userEvent.click(btn);
|
||||
expect(setSel).toHaveBeenCalledWith("r2");
|
||||
});
|
||||
|
||||
test("does not apply selected style when row.getIsSelected() is false", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1", false);
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).not.toHaveClass("bg-slate-100");
|
||||
});
|
||||
|
||||
test("applies selected style when row.getIsSelected() is true", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1", true);
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("bg-slate-100");
|
||||
});
|
||||
|
||||
test("renders collapsed height class when isExpanded is false", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
const inner = container.querySelector("div > div");
|
||||
expect(inner).toHaveClass("h-10");
|
||||
});
|
||||
|
||||
test("renders expanded height class when isExpanded is true", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={true}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
const inner = container.querySelector("div > div");
|
||||
expect(inner).toHaveClass("h-full");
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,13 @@ export const ResponseTableCell = ({
|
||||
|
||||
// Conditional rendering of maximize icon
|
||||
const renderMaximizeIcon = cell.column.id === "createdAt" && (
|
||||
<div
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConsentSummary } from "./ConsentSummary";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
|
||||
() => ({
|
||||
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("ConsentSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockSetFilter = vi.fn();
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { en: "Headline" },
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
} as unknown as TSurveyConsentQuestion,
|
||||
accepted: { percentage: 60.5, count: 61 },
|
||||
dismissed: { percentage: 39.5, count: 40 },
|
||||
} as unknown as TSurveyQuestionSummaryConsent;
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders accepted and dismissed with correct values", () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
expect(screen.getByText("common.accepted")).toBeInTheDocument();
|
||||
expect(screen.getByText(/60\.5%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/61/)).toBeInTheDocument();
|
||||
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
|
||||
expect(screen.getByText(/39\.5%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/40/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on accepted click", async () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
await userEvent.click(screen.getByText("common.accepted"));
|
||||
expect(mockSetFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
{ en: "Headline" },
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
"is",
|
||||
"common.accepted"
|
||||
);
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on dismissed click", async () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
await userEvent.click(screen.getByText("common.dismissed"));
|
||||
expect(mockSetFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
{ en: "Headline" },
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
"is",
|
||||
"common.dismissed"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders singular and plural response labels", () => {
|
||||
const oneAndTwo = {
|
||||
...questionSummary,
|
||||
accepted: { percentage: questionSummary.accepted.percentage, count: 1 },
|
||||
dismissed: { percentage: questionSummary.dismissed.percentage, count: 2 },
|
||||
};
|
||||
render(<ConsentSummary questionSummary={oneAndTwo} survey={survey} setFilter={mockSetFilter} />);
|
||||
expect(screen.getByText(/1 common\.response/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -41,11 +41,11 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
<button
|
||||
className="group w-full cursor-pointer"
|
||||
key={summaryItem.title}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={summaryItem.percentage / 100} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MatrixQuestionSummary } from "./MatrixQuestionSummary";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
|
||||
() => ({
|
||||
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("MatrixQuestionSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = { id: "s1" } as any;
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Q Head", type: "matrix" },
|
||||
data: [
|
||||
{
|
||||
rowLabel: "Row1",
|
||||
totalResponsesForRow: 10,
|
||||
columnPercentages: [
|
||||
{ column: "Yes", percentage: 50 },
|
||||
{ column: "No", percentage: 50 },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
test("renders headers and buttons, click triggers setFilter", async () => {
|
||||
const setFilter = vi.fn();
|
||||
render(<MatrixQuestionSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
|
||||
|
||||
// column headers
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
expect(screen.getByText("No")).toBeInTheDocument();
|
||||
// row label
|
||||
expect(screen.getByText("Row1")).toBeInTheDocument();
|
||||
// buttons
|
||||
const btn = screen.getAllByRole("button", { name: /50/ });
|
||||
await userEvent.click(btn[0]);
|
||||
expect(setFilter).toHaveBeenCalledWith("q1", "Q Head", "matrix", "Row1", "Yes");
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="text-center font-medium">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
|
||||
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
@@ -81,7 +81,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
percentage,
|
||||
questionSummary.data[rowIndex].totalResponsesForRow
|
||||
)}>
|
||||
<div
|
||||
<button
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
)
|
||||
}>
|
||||
{percentage}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipRenderer>
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MultipleChoiceSummary } from "./MultipleChoiceSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
|
||||
|
||||
describe("MultipleChoiceSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseSurvey = { id: "s1" } as any;
|
||||
const envId = "env";
|
||||
|
||||
test("renders header and choice button", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q",
|
||||
headline: "H",
|
||||
type: "multipleChoiceSingle",
|
||||
choices: [{ id: "c", label: { default: "C" } }],
|
||||
},
|
||||
choices: { C: { value: "C", count: 1, percentage: 100, others: [] } },
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("header")).toBeDefined();
|
||||
const btn = screen.getByText("1 - C");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q",
|
||||
"H",
|
||||
"multipleChoiceSingle",
|
||||
"environments.surveys.summary.includes_either",
|
||||
["C"]
|
||||
);
|
||||
});
|
||||
|
||||
test("renders others and load more for link", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const others = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: `O${i}`,
|
||||
contact: { id: `id${i}` },
|
||||
contactAttributes: {},
|
||||
}));
|
||||
const q = {
|
||||
question: {
|
||||
id: "q2",
|
||||
headline: "H2",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [{ id: "c2", label: { default: "X" } }],
|
||||
},
|
||||
choices: { X: { value: "X", count: 0, percentage: 0, others } },
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 5,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeDefined();
|
||||
expect(screen.getAllByText(/^O/)).toHaveLength(10);
|
||||
await userEvent.click(screen.getByText("common.load_more"));
|
||||
expect(screen.getAllByText(/^O/)).toHaveLength(12);
|
||||
});
|
||||
|
||||
test("renders others with avatar for app", () => {
|
||||
const setFilter = vi.fn();
|
||||
const others = [{ value: "Val", contact: { id: "uid" }, contactAttributes: {} }];
|
||||
const q = {
|
||||
question: {
|
||||
id: "q3",
|
||||
headline: "H3",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [{ id: "c3", label: { default: "L" } }],
|
||||
},
|
||||
choices: { L: { value: "L", count: 0, percentage: 0, others } },
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 1,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="app"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("avatar")).toBeDefined();
|
||||
expect(screen.getByText("Val")).toBeDefined();
|
||||
});
|
||||
|
||||
test("places choice without others before one with others", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
A: { value: "A", count: 0, percentage: 0, others: [] },
|
||||
B: { value: "B", count: 0, percentage: 0, others: [{ value: "x" }] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - A");
|
||||
expect(btns[1]).toHaveTextContent("1 - B");
|
||||
});
|
||||
|
||||
test("sorts by count when neither has others", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
X: { value: "X", count: 1, percentage: 50, others: [] },
|
||||
Y: { value: "Y", count: 2, percentage: 50, others: [] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
|
||||
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
|
||||
});
|
||||
|
||||
test("places choice with others after one without when reversed inputs", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
C: { value: "C", count: 1, percentage: 0, others: [{ value: "z" }] },
|
||||
D: { value: "D", count: 1, percentage: 0, others: [] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - D");
|
||||
expect(btns[1]).toHaveTextContent("1 - C");
|
||||
});
|
||||
|
||||
test("multi type non-other uses includes_all", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q4",
|
||||
headline: "H4",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [
|
||||
{ id: "other", label: { default: "O" } },
|
||||
{ id: "c4", label: { default: "C4" } },
|
||||
],
|
||||
},
|
||||
choices: {
|
||||
O: { value: "O", count: 1, percentage: 10, others: [] },
|
||||
C4: { value: "C4", count: 2, percentage: 20, others: [] },
|
||||
},
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByText("2 - C4");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q4",
|
||||
"H4",
|
||||
"multipleChoiceMulti",
|
||||
"environments.surveys.summary.includes_all",
|
||||
["C4"]
|
||||
);
|
||||
});
|
||||
|
||||
test("multi type other uses includes_either", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q5",
|
||||
headline: "H5",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [
|
||||
{ id: "other", label: { default: "O5" } },
|
||||
{ id: "c5", label: { default: "C5" } },
|
||||
],
|
||||
},
|
||||
choices: {
|
||||
O5: { value: "O5", count: 1, percentage: 10, others: [] },
|
||||
C5: { value: "C5", count: 0, percentage: 0, others: [] },
|
||||
},
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByText("2 - O5");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q5",
|
||||
"H5",
|
||||
"multipleChoiceMulti",
|
||||
"environments.surveys.summary.includes_either",
|
||||
["O5"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
@@ -45,10 +45,15 @@ export const MultipleChoiceSummary = ({
|
||||
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||
// sort by count and transform to array
|
||||
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||
if (a.others) return 1; // Always put a after b if a has 'others'
|
||||
if (b.others) return -1; // Always put b after a if b has 'others'
|
||||
const aHasOthers = (a.others?.length ?? 0) > 0;
|
||||
const bHasOthers = (b.others?.length ?? 0) > 0;
|
||||
|
||||
return b.count - a.count; // Sort by count
|
||||
// if one has “others” and the other doesn’t, push the one with others to the end
|
||||
if (aHasOthers && !bHasOthers) return 1;
|
||||
if (!aHasOthers && bHasOthers) return -1;
|
||||
|
||||
// if they’re “tied” on having others, fall back to count
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
const handleLoadMore = (e: React.MouseEvent) => {
|
||||
@@ -80,40 +85,41 @@ export const MultipleChoiceSummary = ({
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<div
|
||||
key={result.value}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">
|
||||
{t("environments.surveys.summary.other_values_found")}
|
||||
@@ -124,11 +130,9 @@ export const MultipleChoiceSummary = ({
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={idx} dir="auto">
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
{surveyType === "link" && (
|
||||
<div
|
||||
key={idx}
|
||||
className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -139,7 +143,6 @@ export const MultipleChoiceSummary = ({
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
key={idx}
|
||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
@@ -163,7 +166,7 @@ export const MultipleChoiceSummary = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types";
|
||||
import { NPSSummary } from "./NPSSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
HalfCircle: ({ value }: { value: number }) => <div data-testid="half-circle">{value}</div>,
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("NPSSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseQuestion = { id: "q1", headline: "Question?", type: "nps" as const };
|
||||
const summary = {
|
||||
question: baseQuestion,
|
||||
promoters: { count: 2, percentage: 50 },
|
||||
passives: { count: 1, percentage: 25 },
|
||||
detractors: { count: 1, percentage: 25 },
|
||||
dismissed: { count: 0, percentage: 0 },
|
||||
score: 25,
|
||||
} as unknown as TSurveyQuestionSummaryNps;
|
||||
const survey = {} as any;
|
||||
|
||||
test("renders header, groups, ProgressBar and HalfCircle", () => {
|
||||
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={() => {}} />);
|
||||
expect(screen.getByTestId("question-summary-header")).toBeDefined();
|
||||
["promoters", "passives", "detractors", "dismissed"].forEach((g) =>
|
||||
expect(screen.getByText(g)).toBeDefined()
|
||||
);
|
||||
expect(screen.getAllByTestId("progress-bar")[0]).toBeDefined();
|
||||
expect(screen.getByTestId("half-circle")).toHaveTextContent("25");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["promoters", "environments.surveys.summary.includes_either", ["9", "10"]],
|
||||
["passives", "environments.surveys.summary.includes_either", ["7", "8"]],
|
||||
["detractors", "environments.surveys.summary.is_less_than", "7"],
|
||||
["dismissed", "common.skipped", undefined],
|
||||
])("clicking %s calls setFilter correctly", async (group, cmp, vals) => {
|
||||
const setFilter = vi.fn();
|
||||
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={setFilter} />);
|
||||
await userEvent.click(screen.getByText(group));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
baseQuestion.id,
|
||||
baseQuestion.headline,
|
||||
baseQuestion.type,
|
||||
cmp,
|
||||
vals
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -62,14 +62,17 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
@@ -87,11 +90,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<div className="flex justify-center pt-4 pb-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { PictureChoiceSummary } from "./PictureChoiceSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress }: { progress: number }) => (
|
||||
<div data-testid="progress-bar" data-progress={progress} />
|
||||
),
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
// mock next image
|
||||
vi.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src }: { src: string }) => <img src={src} alt="" />,
|
||||
}));
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
describe("PictureChoiceSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders choices with formatted percentages and counts", () => {
|
||||
const choices = [
|
||||
{ id: "1", imageUrl: "img1.png", percentage: 33.3333, count: 1 },
|
||||
{ id: "2", imageUrl: "img2.png", percentage: 66.6667, count: 2 },
|
||||
];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: { id: "q1", type: TSurveyQuestionTypeEnum.PictureSelection, headline: "H", allowMulti: true },
|
||||
selectionCount: 3,
|
||||
} as any;
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
|
||||
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
expect(screen.getByText("33.33%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.selection")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 common.selections")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("progress-bar")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on click", async () => {
|
||||
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 25, count: 10 }];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: "H1",
|
||||
allowMulti: true,
|
||||
},
|
||||
selectionCount: 10,
|
||||
} as any;
|
||||
const setFilter = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
"H1",
|
||||
TSurveyQuestionTypeEnum.PictureSelection,
|
||||
"environments.surveys.summary.includes_all",
|
||||
["environments.surveys.edit.picture_idx"]
|
||||
);
|
||||
});
|
||||
|
||||
test("hides additionalInfo when allowMulti is false", () => {
|
||||
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 50, count: 5 }];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: "H2",
|
||||
allowMulti: false,
|
||||
},
|
||||
selectionCount: 5,
|
||||
} as any;
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,10 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.id}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -79,7 +79,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types";
|
||||
import { RatingSummary } from "./RatingSummary";
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
describe("RatingSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders overall average and choices", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "star",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 3.1415,
|
||||
choices: [
|
||||
{ rating: 1, percentage: 50, count: 2 },
|
||||
{ rating: 2, percentage: 50, count: 3 },
|
||||
],
|
||||
dismissed: { count: 0 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
expect(screen.getByText("environments.surveys.summary.overall: 3.14")).toBeDefined();
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("clicking a choice calls setFilter with correct args", async () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "number",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 2,
|
||||
choices: [{ rating: 3, percentage: 100, count: 1 }],
|
||||
dismissed: { count: 0 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
"Headline",
|
||||
"rating",
|
||||
"environments.surveys.summary.is_equal_to",
|
||||
"3"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders dismissed section when dismissed count > 0", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "smiley",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 4,
|
||||
choices: [],
|
||||
dismissed: { count: 1 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
expect(screen.getByText("common.dismissed")).toBeDefined();
|
||||
expect(screen.getByText("1 common.response")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -50,10 +50,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
ChevronDownIcon: () => <div data-testid="down" />,
|
||||
ChevronUpIcon: () => <div data-testid="up" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const baseSummary = {
|
||||
completedPercentage: 50,
|
||||
completedResponses: 2,
|
||||
displayCount: 3,
|
||||
dropOffPercentage: 25,
|
||||
dropOffCount: 1,
|
||||
startsPercentage: 75,
|
||||
totalResponses: 4,
|
||||
ttcAverage: 65000,
|
||||
};
|
||||
|
||||
describe("SummaryMetadata", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading skeletons when isLoading=true", () => {
|
||||
const { container } = render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
// impressions, starts, completed, drop_offs, ttc
|
||||
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("75%")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("50%")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats time correctly when < 60 seconds", () => {
|
||||
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={smallSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("5.00s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
|
||||
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
surveySummary={zeroSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders '-' for displayCount=0", () => {
|
||||
const dispZero = { ...baseSummary, displayCount: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={dispZero}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("renders '-' for totalResponses=0", () => {
|
||||
const totZero = { ...baseSummary, totalResponses: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={totZero}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ export const SummaryMetadata = ({
|
||||
ttcAverage,
|
||||
} = surveySummary;
|
||||
const { t } = useTranslate();
|
||||
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
|
||||
@@ -99,9 +101,7 @@ export const SummaryMetadata = ({
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}
|
||||
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -112,20 +112,20 @@ export const SummaryMetadata = ({
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : dropOffCount === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
dropOffCount
|
||||
displayCountValue
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
<button
|
||||
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,6 +135,7 @@ export const SummaryMetadata = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
percentage={null}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { QuestionFilterComboBox } from "./QuestionFilterComboBox";
|
||||
|
||||
describe("QuestionFilterComboBox", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
filterOptions: ["A", "B"],
|
||||
filterComboBoxOptions: ["X", "Y"],
|
||||
filterValue: undefined,
|
||||
filterComboBoxValue: undefined,
|
||||
onChangeFilterValue: vi.fn(),
|
||||
onChangeFilterComboBoxValue: vi.fn(),
|
||||
handleRemoveMultiSelect: vi.fn(),
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
test("renders select placeholders", () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
|
||||
});
|
||||
|
||||
test("calls onChangeFilterValue when selecting filter", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]);
|
||||
await userEvent.click(screen.getByText("A"));
|
||||
expect(defaultProps.onChangeFilterValue).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
test("calls onChangeFilterComboBoxValue when selecting combo box option", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} filterValue="A" />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
await userEvent.click(screen.getByText("X"));
|
||||
expect(defaultProps.onChangeFilterComboBoxValue).toHaveBeenCalledWith("X");
|
||||
});
|
||||
|
||||
test("multi-select removal works", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "multipleChoiceMulti",
|
||||
filterValue: "A",
|
||||
filterComboBoxValue: ["X", "Y"],
|
||||
};
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const removeButtons = screen.getAllByRole("button", { name: /X/i });
|
||||
await userEvent.click(removeButtons[0]);
|
||||
expect(props.handleRemoveMultiSelect).toHaveBeenCalledWith(["Y"]);
|
||||
});
|
||||
|
||||
test("disabled state prevents opening", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} disabled />);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]);
|
||||
expect(screen.queryByText("A")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles object options correctly", async () => {
|
||||
const obj = { default: "Obj1", en: "ObjEN" };
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "multipleChoiceMulti",
|
||||
filterValue: "A",
|
||||
filterComboBoxOptions: [obj],
|
||||
filterComboBoxValue: [],
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
await userEvent.click(screen.getByText("Obj1"));
|
||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Submitted", async () => {
|
||||
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Skipped", async () => {
|
||||
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,39 @@ export const QuestionFilterComboBox = ({
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const commandItemOnSelect = (o: string) => {
|
||||
if (!isMultiple) {
|
||||
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||
} else {
|
||||
onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
}
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
@@ -130,39 +163,37 @@ export const QuestionFilterComboBox = ({
|
||||
)}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
|
||||
!Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
@@ -183,21 +214,7 @@ export const QuestionFilterComboBox = ({
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => {
|
||||
!isMultiple
|
||||
? onChangeFilterComboBoxValue(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
: onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [
|
||||
...filterComboBoxValue,
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o,
|
||||
]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
!isMultiple && setOpen(false);
|
||||
}}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockOptions: QuestionOptions[] = [
|
||||
{
|
||||
header: OptionsType.QUESTIONS,
|
||||
option: [{ label: "Q1", type: OptionsType.QUESTIONS, questionType: undefined, id: "1" }],
|
||||
},
|
||||
{
|
||||
header: OptionsType.TAGS,
|
||||
option: [{ label: "Tag1", type: OptionsType.TAGS, id: "t1" }],
|
||||
},
|
||||
];
|
||||
|
||||
test("renders selected label when closed", () => {
|
||||
const selected: Partial<QuestionOption> = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" };
|
||||
render(<QuestionsComboBox options={mockOptions} selected={selected} onChangeValue={() => {}} />);
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens dropdown, selects an option, and closes", async () => {
|
||||
let currentSelected: Partial<QuestionOption> = {};
|
||||
const onChange = vi.fn((option) => {
|
||||
currentSelected = option;
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />
|
||||
);
|
||||
|
||||
// Open the dropdown
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByPlaceholderText("common.search...")).toBeInTheDocument();
|
||||
|
||||
// Select an option
|
||||
await userEvent.click(screen.getByText("Q1"));
|
||||
|
||||
// Check if onChange was called
|
||||
expect(onChange).toHaveBeenCalledWith(mockOptions[0].option[0]);
|
||||
|
||||
// Rerender with the new selected value
|
||||
rerender(<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />);
|
||||
|
||||
// Check if the input is gone and the selected item is displayed
|
||||
expect(screen.queryByPlaceholderText("common.search...")).toBeNull();
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
StarIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export enum OptionsType {
|
||||
@@ -141,15 +141,15 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
|
||||
};
|
||||
|
||||
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const commandRef = React.useRef(null);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const commandRef = useRef(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
return (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<div
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
@@ -174,14 +174,14 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<>
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
@@ -199,7 +199,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,8 @@ export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date =
|
||||
} else if (billing.period === "yearly" && billing.periodStart) {
|
||||
// For yearly plans, use the same day of the month as the original subscription date
|
||||
const periodStart = new Date(billing.periodStart);
|
||||
const subscriptionDay = periodStart.getDate();
|
||||
// Use UTC to avoid timezone-offset shifting when parsing ISO date-only strings
|
||||
const subscriptionDay = periodStart.getUTCDate();
|
||||
|
||||
// Helper function to get the last day of a specific month
|
||||
const getLastDayOfMonth = (year: number, month: number): number => {
|
||||
|
||||
@@ -28,7 +28,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
className="absolute top-12 z-30 w-fit rounded-lg border bg-slate-900 p-1 text-sm text-white"
|
||||
ref={languageDropdownRef}>
|
||||
{enabledLanguages.map((surveyLanguage) => (
|
||||
<div
|
||||
<button
|
||||
key={surveyLanguage.language.code}
|
||||
className="rounded-md p-2 hover:cursor-pointer hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
@@ -36,7 +36,7 @@ export const LanguageDropdown = ({ survey, setLanguage, locale }: LanguageDropdo
|
||||
setShowLanguageSelect(false);
|
||||
}}>
|
||||
{getLanguageLabel(surveyLanguage.language.code, locale)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
105
apps/web/modules/ui/components/progress-bar/index.test.tsx
Normal file
105
apps/web/modules/ui/components/progress-bar/index.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { HalfCircle, ProgressBar } from ".";
|
||||
|
||||
describe("ProgressBar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders with default height and correct progress", () => {
|
||||
const { container } = render(<ProgressBar progress={0.5} barColor="bg-blue-500" />);
|
||||
const outerDiv = container.firstChild as HTMLElement;
|
||||
const innerDiv = outerDiv.firstChild as HTMLElement;
|
||||
|
||||
expect(outerDiv).toHaveClass("h-5"); // Default height
|
||||
expect(outerDiv).toHaveClass("w-full rounded-full bg-slate-200");
|
||||
expect(innerDiv).toHaveClass("h-full rounded-full bg-blue-500");
|
||||
expect(innerDiv.style.width).toBe("50%");
|
||||
});
|
||||
|
||||
test("renders with specified height (h-2)", () => {
|
||||
const { container } = render(<ProgressBar progress={0.75} barColor="bg-green-500" height={2} />);
|
||||
const outerDiv = container.firstChild as HTMLElement;
|
||||
const innerDiv = outerDiv.firstChild as HTMLElement;
|
||||
|
||||
expect(outerDiv).toHaveClass("h-2"); // Specified height
|
||||
expect(innerDiv).toHaveClass("bg-green-500");
|
||||
expect(innerDiv.style.width).toBe("75%");
|
||||
});
|
||||
|
||||
test("caps progress at 100%", () => {
|
||||
const { container } = render(<ProgressBar progress={1.2} barColor="bg-red-500" />);
|
||||
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerDiv.style.width).toBe("100%");
|
||||
});
|
||||
|
||||
test("handles progress less than 0%", () => {
|
||||
const { container } = render(<ProgressBar progress={-0.1} barColor="bg-yellow-500" />);
|
||||
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerDiv.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
test("applies barColor class", () => {
|
||||
const testColor = "bg-purple-600";
|
||||
const { container } = render(<ProgressBar progress={0.3} barColor={testColor} />);
|
||||
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerDiv).toHaveClass(testColor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HalfCircle", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly with a given value", () => {
|
||||
const testValue = 50;
|
||||
const { getByText, container } = render(<HalfCircle value={testValue} />);
|
||||
|
||||
// Check if boundary values and the main value are rendered
|
||||
expect(getByText("-100")).toBeInTheDocument();
|
||||
expect(getByText("100")).toBeInTheDocument();
|
||||
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
|
||||
|
||||
// Check rotation calculation: normalized = (50 + 100) / 200 = 0.75; mapped = (0.75 * 180 - 180) = -45deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv).toBeInTheDocument();
|
||||
expect(rotatingDiv.style.rotate).toBe("-45deg");
|
||||
});
|
||||
|
||||
test("renders correctly with value -100", () => {
|
||||
const testValue = -100;
|
||||
const { getAllByText, getByText, container } = render(<HalfCircle value={testValue} />);
|
||||
// Check boundary labels
|
||||
expect(getAllByText("-100")[0]).toBeInTheDocument();
|
||||
expect(getByText("100")).toBeInTheDocument();
|
||||
|
||||
// Check the main value using a more specific selector
|
||||
const mainValueElement = container.querySelector(".text-2xl.text-black");
|
||||
expect(mainValueElement).toBeInTheDocument();
|
||||
expect(mainValueElement?.textContent).toBe(Math.round(testValue).toString());
|
||||
|
||||
// normalized = (-100 + 100) / 200 = 0; mapped = (0 * 180 - 180) = -180deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv.style.rotate).toBe("-180deg");
|
||||
});
|
||||
|
||||
test("renders correctly with value 100", () => {
|
||||
const testValue = 100;
|
||||
const { getAllByText, container } = render(<HalfCircle value={testValue} />);
|
||||
expect(getAllByText(Math.round(testValue).toString())[0]).toBeInTheDocument();
|
||||
// normalized = (100 + 100) / 200 = 1; mapped = (1 * 180 - 180) = 0deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv.style.rotate).toBe("0deg");
|
||||
});
|
||||
|
||||
test("renders correctly with value 0", () => {
|
||||
const testValue = 0;
|
||||
const { getByText, container } = render(<HalfCircle value={testValue} />);
|
||||
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
|
||||
// normalized = (0 + 100) / 200 = 0.5; mapped = (0.5 * 180 - 180) = -90deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv.style.rotate).toBe("-90deg");
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,24 @@ interface ProgressBarProps {
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, barColor, height = 5 }) => {
|
||||
const heightClass = () => {
|
||||
switch (height) {
|
||||
case 2:
|
||||
return "h-2";
|
||||
case 5:
|
||||
return "h-5";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const maxWidth = Math.floor(Math.max(0, Math.min(progress, 1)) * 100);
|
||||
|
||||
return (
|
||||
<div className={cn(height === 2 ? "h-2" : height === 5 ? "h-5" : "", "w-full rounded-full bg-slate-200")}>
|
||||
<div className={cn(heightClass(), "w-full rounded-full bg-slate-200")}>
|
||||
<div
|
||||
className={cn("h-full rounded-full", barColor)}
|
||||
style={{ width: `${Math.floor(progress * 100)}%`, transition: "width 0.5s ease-out" }}></div>
|
||||
style={{ width: `${maxWidth}%`, transition: "width 0.5s ease-out" }}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const nextConfig = {
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
i18n: {
|
||||
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],
|
||||
|
||||
@@ -35,6 +35,7 @@ export default defineConfig({
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/ui/components/alert/*.tsx",
|
||||
"modules/ui/components/environmentId-base-layout/*.tsx",
|
||||
"modules/ui/components/progress-bar/index.tsx",
|
||||
"app/(app)/environments/**/layout.tsx",
|
||||
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
|
||||
"app/(app)/environments/**/components/PosthogIdentify.tsx",
|
||||
@@ -47,6 +48,20 @@ export default defineConfig({
|
||||
"app/intercom/*.tsx",
|
||||
"app/sentry/*.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MatrixQuestionSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MultipleChoiceSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/NPSSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/PictureChoiceSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RatingSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryMetadata.tsx",
|
||||
"app/(app)/environments/**/surveys/**/components/QuestionFilterComboBox.tsx",
|
||||
"app/(app)/environments/**/surveys/**/components/QuestionsComboBox.tsx",
|
||||
"app/(app)/environments/**/integrations/airtable/components/ManageIntegration.tsx",
|
||||
"app/(app)/environments/**/integrations/google-sheets/components/ManageIntegration.tsx",
|
||||
"apps/web/app/(app)/environments/**/integrations/notion/components/ManageIntegration.tsx",
|
||||
"app/(app)/environments/**/integrations/slack/components/ManageIntegration.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
|
||||
"modules/ee/sso/lib/**/*.ts",
|
||||
"app/lib/**/*.ts",
|
||||
"app/api/(internal)/insights/lib/**/*.ts",
|
||||
@@ -75,8 +90,9 @@ export default defineConfig({
|
||||
"modules/analysis/**/*.tsx",
|
||||
"modules/analysis/**/*.ts",
|
||||
"modules/survey/editor/components/end-screen-form.tsx",
|
||||
"lib/utils/billing.ts",
|
||||
"lib/crypto.ts",
|
||||
"lib/utils/billing.ts"
|
||||
"lib/utils/billing.ts",
|
||||
],
|
||||
exclude: [
|
||||
"**/.next/**",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
xmlns:tools="http://schemas.android.com/tools" >
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
|
||||
@@ -5,10 +5,7 @@
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
<!-- No specific include/exclude -->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">192.168.0.200</domain>
|
||||
<domain-config cleartextTrafficPermitted="false">
|
||||
<domain includeSubdomains="true">192.168.29.120</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -23,7 +23,7 @@ android {
|
||||
enableAndroidTestCoverage = true
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<uses-sdk android:minSdkVersion="24" android:targetSdkVersion="35" />
|
||||
</manifest>
|
||||
@@ -182,12 +182,8 @@ object SurveyManager {
|
||||
}
|
||||
|
||||
private fun stopDisplayTimer() {
|
||||
try {
|
||||
displayTimer.cancel()
|
||||
displayTimer = Timer()
|
||||
} catch (_: Exception) {
|
||||
|
||||
}
|
||||
displayTimer.cancel()
|
||||
displayTimer = Timer()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -151,11 +151,6 @@ class FormbricksFragment : BottomSheetDialogFragment() {
|
||||
dialog?.window?.setDimAmount(0.0f)
|
||||
binding.formbricksWebview.setBackgroundColor(Color.TRANSPARENT)
|
||||
binding.formbricksWebview.let {
|
||||
|
||||
if (Formbricks.loggingEnabled) {
|
||||
WebView.setWebContentsDebuggingEnabled(true)
|
||||
}
|
||||
|
||||
it.webChromeClient = object : WebChromeClient() {
|
||||
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
|
||||
consoleMessage?.let { cm ->
|
||||
|
||||
2
packages/ios/.gitignore
vendored
Normal file
2
packages/ios/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Xcode user-specific UI state
|
||||
**/xcuserdata/
|
||||
@@ -1,10 +1,29 @@
|
||||
import UIKit
|
||||
import FormbricksSDK
|
||||
|
||||
class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
class AppDelegate: NSObject, UIApplicationDelegate, FormbricksDelegate {
|
||||
|
||||
func application(_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
Formbricks.delegate = self
|
||||
return true
|
||||
}
|
||||
|
||||
// MARK: - FormbricksDelegate
|
||||
|
||||
func onSurveyStarted() {
|
||||
print("from the delegate: survey started")
|
||||
}
|
||||
|
||||
func onSurveyFinished() {
|
||||
print("survey finished")
|
||||
}
|
||||
|
||||
func onSurveyClosed() {
|
||||
print("survey closed")
|
||||
}
|
||||
|
||||
func onError(_ error: Error) {
|
||||
print("survey error:", error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"originHash" : "92c0230fb0adc404299bb05aba6c51a76f86c388fdfb9f4e9bed3a757f80fc07",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "anycodable",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/Flight-School/AnyCodable",
|
||||
"state" : {
|
||||
"revision" : "862808b2070cd908cb04f9aafe7de83d35f81b05",
|
||||
"version" : "0.6.7"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Bucket
|
||||
uuid = "35F4AE9B-6A79-49B3-AB4C-F05B2141AB6E"
|
||||
type = "0"
|
||||
version = "2.0">
|
||||
</Bucket>
|
||||
@@ -7,7 +7,6 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
4D7D8DD62DB14F18002C453E /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = 4D7D8DD52DB14F18002C453E /* AnyCodable */; };
|
||||
4DDAED692D50D49B00A19B1F /* FormbricksSDK.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
@@ -37,7 +36,6 @@
|
||||
4DDAED9C2D50D54A00A19B1F /* Exceptions for "FormbricksSDK" folder in "FormbricksSDKTests" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Config.swift,
|
||||
"Extension/Calendar+DaysBetween.swift",
|
||||
"Extension/Error+Message.swift",
|
||||
"Extension/JSON+Formatter.swift",
|
||||
@@ -109,7 +107,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
4D7D8DD62DB14F18002C453E /* AnyCodable in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -181,7 +178,6 @@
|
||||
);
|
||||
name = FormbricksSDK;
|
||||
packageProductDependencies = (
|
||||
4D7D8DD52DB14F18002C453E /* AnyCodable */,
|
||||
);
|
||||
productName = FormbricksSDK;
|
||||
productReference = 4DDAED602D50D49A00A19B1F /* FormbricksSDK.framework */;
|
||||
@@ -239,7 +235,6 @@
|
||||
mainGroup = 4DDAED562D50D49A00A19B1F;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
4DA4A0952DB14E67007299C0 /* XCRemoteSwiftPackageReference "AnyCodable" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 4DDAED612D50D49A00A19B1F /* Products */;
|
||||
@@ -550,25 +545,6 @@
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
|
||||
/* Begin XCRemoteSwiftPackageReference section */
|
||||
4DA4A0952DB14E67007299C0 /* XCRemoteSwiftPackageReference "AnyCodable" */ = {
|
||||
isa = XCRemoteSwiftPackageReference;
|
||||
repositoryURL = "https://github.com/Flight-School/AnyCodable";
|
||||
requirement = {
|
||||
kind = exactVersion;
|
||||
version = 0.6.7;
|
||||
};
|
||||
};
|
||||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
4D7D8DD52DB14F18002C453E /* AnyCodable */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
package = 4DA4A0952DB14E67007299C0 /* XCRemoteSwiftPackageReference "AnyCodable" */;
|
||||
productName = AnyCodable;
|
||||
};
|
||||
/* End XCSwiftPackageProductDependency section */
|
||||
};
|
||||
rootObject = 4DDAED572D50D49A00A19B1F /* Project object */;
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<dict>
|
||||
<key>FormbricksSDK.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>SuppressBuildableAutocreation</key>
|
||||
<dict>
|
||||
<key>4DDAED5F2D50D49A00A19B1F</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>4DDAED672D50D49B00A19B1F</key>
|
||||
<dict>
|
||||
<key>primary</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
extension Error {
|
||||
public extension Error {
|
||||
var message: String {
|
||||
if let error = self as? RuntimeError {
|
||||
return error.message
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
/// Formbricks SDK delegate protocol. It contains the main methods to interact with the SDK.
|
||||
public protocol FormbricksDelegate: AnyObject {
|
||||
func onSurveyStarted()
|
||||
func onSurveyFinished()
|
||||
func onSurveyClosed()
|
||||
func onError(_ error: Error)
|
||||
}
|
||||
|
||||
/// The main class of the Formbricks SDK. It contains the main methods to interact with the SDK.
|
||||
@objc(Formbricks) public class Formbricks: NSObject {
|
||||
|
||||
@@ -15,6 +23,7 @@ import Network
|
||||
static internal var apiQueue: OperationQueue? = OperationQueue()
|
||||
static internal var logger: Logger?
|
||||
static internal var service = FormbricksService()
|
||||
public static weak var delegate: FormbricksDelegate?
|
||||
|
||||
// make this class not instantiatable outside of the SDK
|
||||
internal override init() {
|
||||
@@ -43,11 +52,14 @@ import Network
|
||||
logger = Logger()
|
||||
apiQueue = OperationQueue()
|
||||
|
||||
if (force == true) {
|
||||
if force {
|
||||
isInitialized = false
|
||||
}
|
||||
|
||||
guard !isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsAlreadyInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsAlreadyInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -88,7 +100,9 @@ import Network
|
||||
*/
|
||||
@objc public static func setUserId(_ userId: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -111,7 +125,9 @@ import Network
|
||||
*/
|
||||
@objc public static func setAttribute(_ attribute: String, forKey key: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -129,7 +145,9 @@ import Network
|
||||
*/
|
||||
@objc public static func setAttributes(_ attributes: [String : String]) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -147,7 +165,9 @@ import Network
|
||||
*/
|
||||
@objc public static func setLanguage(_ language: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -170,7 +190,9 @@ import Network
|
||||
*/
|
||||
@objc public static func track(_ action: String) {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -195,7 +217,9 @@ import Network
|
||||
*/
|
||||
@objc public static func logout() {
|
||||
guard Formbricks.isInitialized else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .sdkIsNotInitialized).message)
|
||||
let error = FormbricksSDKError(type: .sdkIsNotInitialized)
|
||||
delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift
|
||||
|
||||
import Foundation
|
||||
/**
|
||||
A type-erased `Codable` value.
|
||||
|
||||
The `AnyCodable` type forwards encoding and decoding responsibilities
|
||||
to an underlying value, hiding its specific underlying type.
|
||||
|
||||
You can encode or decode mixed-type values in dictionaries
|
||||
and other collections that require `Encodable` or `Decodable` conformance
|
||||
by declaring their contained type to be `AnyCodable`.
|
||||
|
||||
- SeeAlso: `AnyEncodable`
|
||||
- SeeAlso: `AnyDecodable`
|
||||
*/
|
||||
struct AnyCodable: Codable {
|
||||
public let value: Any
|
||||
|
||||
public init<T>(_ value: T?) {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: AnyEncodableProtocol, AnyDecodableProtocol {}
|
||||
|
||||
extension AnyCodable: Equatable {
|
||||
public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
case is (Void, Void):
|
||||
return true
|
||||
case let (lhs as Bool, rhs as Bool):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int, rhs as Int):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int8, rhs as Int8):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int16, rhs as Int16):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int32, rhs as Int32):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int64, rhs as Int64):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt, rhs as UInt):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt8, rhs as UInt8):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt16, rhs as UInt16):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt32, rhs as UInt32):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt64, rhs as UInt64):
|
||||
return lhs == rhs
|
||||
case let (lhs as Float, rhs as Float):
|
||||
return lhs == rhs
|
||||
case let (lhs as Double, rhs as Double):
|
||||
return lhs == rhs
|
||||
case let (lhs as String, rhs as String):
|
||||
return lhs == rhs
|
||||
case let (lhs as [String: AnyCodable], rhs as [String: AnyCodable]):
|
||||
return lhs == rhs
|
||||
case let (lhs as [AnyCodable], rhs as [AnyCodable]):
|
||||
return lhs == rhs
|
||||
case let (lhs as [String: Any], rhs as [String: Any]):
|
||||
return NSDictionary(dictionary: lhs) == NSDictionary(dictionary: rhs)
|
||||
case let (lhs as [Any], rhs as [Any]):
|
||||
return NSArray(array: lhs) == NSArray(array: rhs)
|
||||
case is (NSNull, NSNull):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch value {
|
||||
case is Void:
|
||||
return String(describing: nil as Any?)
|
||||
case let value as CustomStringConvertible:
|
||||
return value.description
|
||||
default:
|
||||
return String(describing: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyCodable(\(value.debugDescription))"
|
||||
}
|
||||
return "AnyCodable(\(description))"
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyCodable: ExpressibleByNilLiteral {}
|
||||
extension AnyCodable: ExpressibleByBooleanLiteral {}
|
||||
extension AnyCodable: ExpressibleByIntegerLiteral {}
|
||||
extension AnyCodable: ExpressibleByFloatLiteral {}
|
||||
extension AnyCodable: ExpressibleByStringLiteral {}
|
||||
extension AnyCodable: ExpressibleByStringInterpolation {}
|
||||
extension AnyCodable: ExpressibleByArrayLiteral {}
|
||||
extension AnyCodable: ExpressibleByDictionaryLiteral {}
|
||||
|
||||
|
||||
extension AnyCodable: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch value {
|
||||
case let value as Bool:
|
||||
hasher.combine(value)
|
||||
case let value as Int:
|
||||
hasher.combine(value)
|
||||
case let value as Int8:
|
||||
hasher.combine(value)
|
||||
case let value as Int16:
|
||||
hasher.combine(value)
|
||||
case let value as Int32:
|
||||
hasher.combine(value)
|
||||
case let value as Int64:
|
||||
hasher.combine(value)
|
||||
case let value as UInt:
|
||||
hasher.combine(value)
|
||||
case let value as UInt8:
|
||||
hasher.combine(value)
|
||||
case let value as UInt16:
|
||||
hasher.combine(value)
|
||||
case let value as UInt32:
|
||||
hasher.combine(value)
|
||||
case let value as UInt64:
|
||||
hasher.combine(value)
|
||||
case let value as Float:
|
||||
hasher.combine(value)
|
||||
case let value as Double:
|
||||
hasher.combine(value)
|
||||
case let value as String:
|
||||
hasher.combine(value)
|
||||
case let value as [String: AnyCodable]:
|
||||
hasher.combine(value)
|
||||
case let value as [AnyCodable]:
|
||||
hasher.combine(value)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift
|
||||
|
||||
#if canImport(Foundation)
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/**
|
||||
A type-erased `Decodable` value.
|
||||
|
||||
The `AnyDecodable` type forwards decoding responsibilities
|
||||
to an underlying value, hiding its specific underlying type.
|
||||
|
||||
You can decode mixed-type values in dictionaries
|
||||
and other collections that require `Decodable` conformance
|
||||
by declaring their contained type to be `AnyDecodable`:
|
||||
|
||||
let json = """
|
||||
{
|
||||
"boolean": true,
|
||||
"integer": 42,
|
||||
"double": 3.141592653589793,
|
||||
"string": "string",
|
||||
"array": [1, 2, 3],
|
||||
"nested": {
|
||||
"a": "alpha",
|
||||
"b": "bravo",
|
||||
"c": "charlie"
|
||||
},
|
||||
"null": null
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
let dictionary = try! decoder.decode([String: AnyDecodable].self, from: json)
|
||||
*/
|
||||
struct AnyDecodable: Decodable {
|
||||
public let value: Any
|
||||
|
||||
public init<T>(_ value: T?) {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
protocol AnyDecodableProtocol {
|
||||
var value: Any { get }
|
||||
init<T>(_ value: T?)
|
||||
}
|
||||
|
||||
extension AnyDecodable: AnyDecodableProtocol {}
|
||||
|
||||
extension AnyDecodableProtocol {
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
|
||||
if container.decodeNil() {
|
||||
#if canImport(Foundation)
|
||||
self.init(NSNull())
|
||||
#else
|
||||
self.init(Optional<Self>.none)
|
||||
#endif
|
||||
} else if let bool = try? container.decode(Bool.self) {
|
||||
self.init(bool)
|
||||
} else if let int = try? container.decode(Int.self) {
|
||||
self.init(int)
|
||||
} else if let uint = try? container.decode(UInt.self) {
|
||||
self.init(uint)
|
||||
} else if let double = try? container.decode(Double.self) {
|
||||
self.init(double)
|
||||
} else if let string = try? container.decode(String.self) {
|
||||
self.init(string)
|
||||
} else if let array = try? container.decode([AnyDecodable].self) {
|
||||
self.init(array.map { $0.value })
|
||||
} else if let dictionary = try? container.decode([String: AnyDecodable].self) {
|
||||
self.init(dictionary.mapValues { $0.value })
|
||||
} else {
|
||||
throw DecodingError.dataCorruptedError(in: container, debugDescription: "AnyDecodable value cannot be decoded")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyDecodable: Equatable {
|
||||
public static func == (lhs: AnyDecodable, rhs: AnyDecodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
#if canImport(Foundation)
|
||||
case is (NSNull, NSNull), is (Void, Void):
|
||||
return true
|
||||
#endif
|
||||
case let (lhs as Bool, rhs as Bool):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int, rhs as Int):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int8, rhs as Int8):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int16, rhs as Int16):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int32, rhs as Int32):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int64, rhs as Int64):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt, rhs as UInt):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt8, rhs as UInt8):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt16, rhs as UInt16):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt32, rhs as UInt32):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt64, rhs as UInt64):
|
||||
return lhs == rhs
|
||||
case let (lhs as Float, rhs as Float):
|
||||
return lhs == rhs
|
||||
case let (lhs as Double, rhs as Double):
|
||||
return lhs == rhs
|
||||
case let (lhs as String, rhs as String):
|
||||
return lhs == rhs
|
||||
case let (lhs as [String: AnyDecodable], rhs as [String: AnyDecodable]):
|
||||
return lhs == rhs
|
||||
case let (lhs as [AnyDecodable], rhs as [AnyDecodable]):
|
||||
return lhs == rhs
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyDecodable: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch value {
|
||||
case is Void:
|
||||
return String(describing: nil as Any?)
|
||||
case let value as CustomStringConvertible:
|
||||
return value.description
|
||||
default:
|
||||
return String(describing: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyDecodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyDecodable(\(value.debugDescription))"
|
||||
} else {
|
||||
return "AnyDecodable(\(description))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyDecodable: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch value {
|
||||
case let value as Bool:
|
||||
hasher.combine(value)
|
||||
case let value as Int:
|
||||
hasher.combine(value)
|
||||
case let value as Int8:
|
||||
hasher.combine(value)
|
||||
case let value as Int16:
|
||||
hasher.combine(value)
|
||||
case let value as Int32:
|
||||
hasher.combine(value)
|
||||
case let value as Int64:
|
||||
hasher.combine(value)
|
||||
case let value as UInt:
|
||||
hasher.combine(value)
|
||||
case let value as UInt8:
|
||||
hasher.combine(value)
|
||||
case let value as UInt16:
|
||||
hasher.combine(value)
|
||||
case let value as UInt32:
|
||||
hasher.combine(value)
|
||||
case let value as UInt64:
|
||||
hasher.combine(value)
|
||||
case let value as Float:
|
||||
hasher.combine(value)
|
||||
case let value as Double:
|
||||
hasher.combine(value)
|
||||
case let value as String:
|
||||
hasher.combine(value)
|
||||
case let value as [String: AnyDecodable]:
|
||||
hasher.combine(value)
|
||||
case let value as [AnyDecodable]:
|
||||
hasher.combine(value)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
// https://github.com/Flight-School/AnyCodable/blob/master/Sources/AnyCodable/AnyCodable.swift
|
||||
|
||||
#if canImport(Foundation)
|
||||
import Foundation
|
||||
#endif
|
||||
|
||||
/**
|
||||
A type-erased `Encodable` value.
|
||||
|
||||
The `AnyEncodable` type forwards encoding responsibilities
|
||||
to an underlying value, hiding its specific underlying type.
|
||||
|
||||
You can encode mixed-type values in dictionaries
|
||||
and other collections that require `Encodable` conformance
|
||||
by declaring their contained type to be `AnyEncodable`:
|
||||
|
||||
let dictionary: [String: AnyEncodable] = [
|
||||
"boolean": true,
|
||||
"integer": 42,
|
||||
"double": 3.141592653589793,
|
||||
"string": "string",
|
||||
"array": [1, 2, 3],
|
||||
"nested": [
|
||||
"a": "alpha",
|
||||
"b": "bravo",
|
||||
"c": "charlie"
|
||||
],
|
||||
"null": nil
|
||||
]
|
||||
|
||||
let encoder = JSONEncoder()
|
||||
let json = try! encoder.encode(dictionary)
|
||||
*/
|
||||
struct AnyEncodable: Encodable {
|
||||
public let value: Any
|
||||
|
||||
public init<T>(_ value: T?) {
|
||||
self.value = value ?? ()
|
||||
}
|
||||
}
|
||||
|
||||
@usableFromInline
|
||||
protocol AnyEncodableProtocol {
|
||||
var value: Any { get }
|
||||
init<T>(_ value: T?)
|
||||
}
|
||||
|
||||
extension AnyEncodable: AnyEncodableProtocol {}
|
||||
|
||||
// MARK: - Encodable
|
||||
|
||||
extension AnyEncodableProtocol {
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
|
||||
switch value {
|
||||
#if canImport(Foundation)
|
||||
case is NSNull:
|
||||
try container.encodeNil()
|
||||
#endif
|
||||
case is Void:
|
||||
try container.encodeNil()
|
||||
case let bool as Bool:
|
||||
try container.encode(bool)
|
||||
case let int as Int:
|
||||
try container.encode(int)
|
||||
case let int8 as Int8:
|
||||
try container.encode(int8)
|
||||
case let int16 as Int16:
|
||||
try container.encode(int16)
|
||||
case let int32 as Int32:
|
||||
try container.encode(int32)
|
||||
case let int64 as Int64:
|
||||
try container.encode(int64)
|
||||
case let uint as UInt:
|
||||
try container.encode(uint)
|
||||
case let uint8 as UInt8:
|
||||
try container.encode(uint8)
|
||||
case let uint16 as UInt16:
|
||||
try container.encode(uint16)
|
||||
case let uint32 as UInt32:
|
||||
try container.encode(uint32)
|
||||
case let uint64 as UInt64:
|
||||
try container.encode(uint64)
|
||||
case let float as Float:
|
||||
try container.encode(float)
|
||||
case let double as Double:
|
||||
try container.encode(double)
|
||||
case let string as String:
|
||||
try container.encode(string)
|
||||
#if canImport(Foundation)
|
||||
case let number as NSNumber:
|
||||
try encode(nsnumber: number, into: &container)
|
||||
case let date as Date:
|
||||
try container.encode(date)
|
||||
case let url as URL:
|
||||
try container.encode(url)
|
||||
#endif
|
||||
case let array as [Any?]:
|
||||
try container.encode(array.map { AnyEncodable($0) })
|
||||
case let dictionary as [String: Any?]:
|
||||
try container.encode(dictionary.mapValues { AnyEncodable($0) })
|
||||
case let encodable as Encodable:
|
||||
try encodable.encode(to: encoder)
|
||||
default:
|
||||
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "AnyEncodable value cannot be encoded")
|
||||
throw EncodingError.invalidValue(value, context)
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(Foundation)
|
||||
private func encode(nsnumber: NSNumber, into container: inout SingleValueEncodingContainer) throws {
|
||||
switch Character(Unicode.Scalar(UInt8(nsnumber.objCType.pointee))) {
|
||||
case "B":
|
||||
try container.encode(nsnumber.boolValue)
|
||||
case "c":
|
||||
try container.encode(nsnumber.int8Value)
|
||||
case "s":
|
||||
try container.encode(nsnumber.int16Value)
|
||||
case "i", "l":
|
||||
try container.encode(nsnumber.int32Value)
|
||||
case "q":
|
||||
try container.encode(nsnumber.int64Value)
|
||||
case "C":
|
||||
try container.encode(nsnumber.uint8Value)
|
||||
case "S":
|
||||
try container.encode(nsnumber.uint16Value)
|
||||
case "I", "L":
|
||||
try container.encode(nsnumber.uint32Value)
|
||||
case "Q":
|
||||
try container.encode(nsnumber.uint64Value)
|
||||
case "f":
|
||||
try container.encode(nsnumber.floatValue)
|
||||
case "d":
|
||||
try container.encode(nsnumber.doubleValue)
|
||||
default:
|
||||
let context = EncodingError.Context(codingPath: container.codingPath, debugDescription: "NSNumber cannot be encoded because its type is not handled")
|
||||
throw EncodingError.invalidValue(nsnumber, context)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
extension AnyEncodable: Equatable {
|
||||
public static func == (lhs: AnyEncodable, rhs: AnyEncodable) -> Bool {
|
||||
switch (lhs.value, rhs.value) {
|
||||
case is (Void, Void):
|
||||
return true
|
||||
case let (lhs as Bool, rhs as Bool):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int, rhs as Int):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int8, rhs as Int8):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int16, rhs as Int16):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int32, rhs as Int32):
|
||||
return lhs == rhs
|
||||
case let (lhs as Int64, rhs as Int64):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt, rhs as UInt):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt8, rhs as UInt8):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt16, rhs as UInt16):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt32, rhs as UInt32):
|
||||
return lhs == rhs
|
||||
case let (lhs as UInt64, rhs as UInt64):
|
||||
return lhs == rhs
|
||||
case let (lhs as Float, rhs as Float):
|
||||
return lhs == rhs
|
||||
case let (lhs as Double, rhs as Double):
|
||||
return lhs == rhs
|
||||
case let (lhs as String, rhs as String):
|
||||
return lhs == rhs
|
||||
case let (lhs as [String: AnyEncodable], rhs as [String: AnyEncodable]):
|
||||
return lhs == rhs
|
||||
case let (lhs as [AnyEncodable], rhs as [AnyEncodable]):
|
||||
return lhs == rhs
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyEncodable: CustomStringConvertible {
|
||||
public var description: String {
|
||||
switch value {
|
||||
case is Void:
|
||||
return String(describing: nil as Any?)
|
||||
case let value as CustomStringConvertible:
|
||||
return value.description
|
||||
default:
|
||||
return String(describing: value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyEncodable: CustomDebugStringConvertible {
|
||||
public var debugDescription: String {
|
||||
if let value = value as? CustomDebugStringConvertible {
|
||||
return "AnyEncodable(\(value.debugDescription))"
|
||||
} else {
|
||||
return "AnyEncodable(\(description))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyEncodable: ExpressibleByNilLiteral {}
|
||||
extension AnyEncodable: ExpressibleByBooleanLiteral {}
|
||||
extension AnyEncodable: ExpressibleByIntegerLiteral {}
|
||||
extension AnyEncodable: ExpressibleByFloatLiteral {}
|
||||
extension AnyEncodable: ExpressibleByStringLiteral {}
|
||||
extension AnyEncodable: ExpressibleByStringInterpolation {}
|
||||
extension AnyEncodable: ExpressibleByArrayLiteral {}
|
||||
extension AnyEncodable: ExpressibleByDictionaryLiteral {}
|
||||
|
||||
extension AnyEncodableProtocol {
|
||||
public init(nilLiteral _: ()) {
|
||||
self.init(nil as Any?)
|
||||
}
|
||||
|
||||
public init(booleanLiteral value: Bool) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
public init(integerLiteral value: Int) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
public init(floatLiteral value: Double) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
public init(extendedGraphemeClusterLiteral value: String) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
public init(stringLiteral value: String) {
|
||||
self.init(value)
|
||||
}
|
||||
|
||||
public init(arrayLiteral elements: Any...) {
|
||||
self.init(elements)
|
||||
}
|
||||
|
||||
public init(dictionaryLiteral elements: (AnyHashable, Any)...) {
|
||||
self.init([AnyHashable: Any](elements, uniquingKeysWith: { first, _ in first }))
|
||||
}
|
||||
}
|
||||
|
||||
extension AnyEncodable: Hashable {
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
switch value {
|
||||
case let value as Bool:
|
||||
hasher.combine(value)
|
||||
case let value as Int:
|
||||
hasher.combine(value)
|
||||
case let value as Int8:
|
||||
hasher.combine(value)
|
||||
case let value as Int16:
|
||||
hasher.combine(value)
|
||||
case let value as Int32:
|
||||
hasher.combine(value)
|
||||
case let value as Int64:
|
||||
hasher.combine(value)
|
||||
case let value as UInt:
|
||||
hasher.combine(value)
|
||||
case let value as UInt8:
|
||||
hasher.combine(value)
|
||||
case let value as UInt16:
|
||||
hasher.combine(value)
|
||||
case let value as UInt32:
|
||||
hasher.combine(value)
|
||||
case let value as UInt64:
|
||||
hasher.combine(value)
|
||||
case let value as Float:
|
||||
hasher.combine(value)
|
||||
case let value as Double:
|
||||
hasher.combine(value)
|
||||
case let value as String:
|
||||
hasher.combine(value)
|
||||
case let value as [String: AnyEncodable]:
|
||||
hasher.combine(value)
|
||||
case let value as [AnyEncodable]:
|
||||
hasher.combine(value)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,32 @@
|
||||
import Foundation
|
||||
|
||||
class FormbricksEnvironment {
|
||||
public static let baseApiUrl: String = Formbricks.appUrl ?? "http://localhost:3000"
|
||||
public static let surveyScriptUrl: String = "\(baseApiUrl)/js/surveys.umd.cjs"
|
||||
/// Endpoint for getting environment data. Replace {environmentId} with the actual environment ID.
|
||||
public static let getEnvironmentRequestEndpoint: String = "/api/v2/client/{environmentId}/environment"
|
||||
/// Endpoint for posting user data. Replace {environmentId} with the actual environment ID.
|
||||
public static let postUserRequestEndpoint: String = "/api/v2/client/{environmentId}/user"
|
||||
internal enum FormbricksEnvironment {
|
||||
|
||||
/// Only `appUrl` is user-supplied. Crash early if it’s missing.
|
||||
fileprivate static var baseApiUrl: String {
|
||||
guard let url = Formbricks.appUrl else {
|
||||
fatalError("Formbricks.setup must be called before using the SDK.")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/// Returns the full survey‐script URL as a String
|
||||
static var surveyScriptUrlString: String {
|
||||
let path = "/" + ["js", "surveys.umd.cjs"].joined(separator: "/")
|
||||
return baseApiUrl + path
|
||||
}
|
||||
|
||||
/// Returns the full environment‐fetch URL as a String for the given ID
|
||||
static var getEnvironmentRequestEndpoint: String {
|
||||
let path = "/" + ["api", "v2", "client", "{environmentId}", "environment"]
|
||||
.joined(separator: "/")
|
||||
return path
|
||||
}
|
||||
|
||||
/// Returns the full post-user URL as a String for the given ID
|
||||
static var postUserRequestEndpoint: String {
|
||||
let path = "/" + ["api", "v2", "client", "{environmentId}", "user"]
|
||||
.joined(separator: "/")
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,7 +105,9 @@ extension SurveyManager {
|
||||
self?.filterSurveys()
|
||||
case .failure:
|
||||
self?.hasApiError = true
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRefreshEnvironment).message)
|
||||
let error = FormbricksSDKError(type: .unableToRefreshEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
self?.startErrorTimer()
|
||||
}
|
||||
}
|
||||
@@ -183,7 +185,9 @@ extension SurveyManager {
|
||||
if let data = UserDefaults.standard.data(forKey: SurveyManager.environmentResponseObjectKey) {
|
||||
return try? JSONDecoder().decode(EnvironmentResponse.self, from: data)
|
||||
} else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .unableToRetrieveEnvironment).message)
|
||||
let error = FormbricksSDKError(type: .unableToRetrieveEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -192,7 +196,9 @@ extension SurveyManager {
|
||||
UserDefaults.standard.set(data, forKey: SurveyManager.environmentResponseObjectKey)
|
||||
backingEnvironmentResponse = newValue
|
||||
} else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .unableToPersistEnvironment).message)
|
||||
let error = FormbricksSDKError(type: .unableToPersistEnvironment)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,7 +230,9 @@ private extension SurveyManager {
|
||||
}
|
||||
|
||||
default:
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .invalidDisplayOption).message)
|
||||
let error = FormbricksSDKError(type: .invalidDisplayOption)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ final class UserManager: UserManagerSyncable {
|
||||
self?.surveyManager?.filterSurveys()
|
||||
self?.startSyncTimer()
|
||||
case .failure(let error):
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +133,10 @@ final class UserManager: UserManagerSyncable {
|
||||
backingLastDisplayedAt = nil
|
||||
backingExpiresAt = nil
|
||||
Formbricks.language = "default"
|
||||
updateQueue?.reset()
|
||||
|
||||
syncTimer?.invalidate()
|
||||
syncTimer = nil
|
||||
updateQueue?.cleanup()
|
||||
|
||||
if isUserIdDefined {
|
||||
Formbricks.logger?.debug("Successfully logged out user and reset the user state.")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum FormbricksAPIErrorType: Int {
|
||||
public enum FormbricksAPIErrorType: Int {
|
||||
case invalidResponse
|
||||
case responseError
|
||||
|
||||
@@ -14,12 +14,12 @@ enum FormbricksAPIErrorType: Int {
|
||||
}
|
||||
}
|
||||
|
||||
final class FormbricksAPIClientError: LocalizedError {
|
||||
let type: FormbricksAPIErrorType
|
||||
let statusCodeInt: Int?
|
||||
public final class FormbricksAPIClientError: LocalizedError {
|
||||
public let type: FormbricksAPIErrorType
|
||||
public let statusCodeInt: Int?
|
||||
let statusCode: HTTPStatusCode?
|
||||
|
||||
var errorDescription: String
|
||||
public var errorDescription: String
|
||||
|
||||
init(type: FormbricksAPIErrorType, statusCode: Int? = nil) {
|
||||
self.type = type
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
enum FormbricksSDKErrorType: Int {
|
||||
public enum FormbricksSDKErrorType: Int {
|
||||
case sdkIsNotInitialized
|
||||
case sdkIsAlreadyInitialized
|
||||
case invalidAppUrl
|
||||
@@ -44,9 +44,9 @@ enum FormbricksSDKErrorType: Int {
|
||||
}
|
||||
}
|
||||
|
||||
final class FormbricksSDKError: LocalizedError {
|
||||
let type: FormbricksSDKErrorType
|
||||
var errorDescription: String
|
||||
public final class FormbricksSDKError: LocalizedError {
|
||||
public let type: FormbricksSDKErrorType
|
||||
public var errorDescription: String
|
||||
|
||||
init(type: FormbricksSDKErrorType) {
|
||||
self.type = type
|
||||
|
||||
@@ -39,6 +39,7 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
private func processResponse(data: Data?, response: URLResponse?, error: Error?) {
|
||||
guard let httpStatus = (response as? HTTPURLResponse)?.status else {
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("ERROR \(error.message)")
|
||||
completion?(.failure(error))
|
||||
return
|
||||
@@ -55,7 +56,9 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
|
||||
private func handleSuccessResponse(data: Data?, statusCode: Int, message: inout String) {
|
||||
guard let data = data else {
|
||||
completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)))
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
completion?(.failure(error))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -86,13 +89,16 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
|
||||
if let error = error {
|
||||
log.append("\nError: \(error.localizedDescription)")
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(log)
|
||||
completion?(.failure(error))
|
||||
} else if let data = data, let apiError = try? request.decoder.decode(FormbricksAPIError.self, from: data) {
|
||||
Formbricks.delegate?.onError(apiError)
|
||||
Formbricks.logger?.error("\(log)\n\(apiError.getDetailedErrorMessage())")
|
||||
completion?(.failure(apiError))
|
||||
} else {
|
||||
let error = FormbricksAPIClientError(type: .responseError, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("\(log)\n\(error.message)")
|
||||
completion?(.failure(error))
|
||||
}
|
||||
@@ -111,9 +117,12 @@ class APIClient<Request: CodableRequest>: Operation, @unchecked Sendable {
|
||||
default:
|
||||
message.append("Error: \(error.localizedDescription)")
|
||||
}
|
||||
|
||||
Formbricks.logger?.error(message)
|
||||
completion?(.failure(FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)))
|
||||
|
||||
let error = FormbricksAPIClientError(type: .invalidResponse, statusCode: statusCode)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
completion?(.failure(error))
|
||||
|
||||
}
|
||||
|
||||
private func logRequest(_ request: URLRequest) {
|
||||
|
||||
@@ -100,7 +100,9 @@ private extension UpdateQueue {
|
||||
let effectiveUserId: String? = self.userId ?? Formbricks.userManager?.userId ?? nil
|
||||
|
||||
guard let userId = effectiveUserId else {
|
||||
Formbricks.logger?.error(FormbricksSDKError(type: .userIdIsNotSetYet).message)
|
||||
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ private extension FormbricksViewModel {
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
script.src = "\(Formbricks.appUrl ?? "http://localhost:3000")/js/surveys.umd.cjs";
|
||||
script.src = "\(FormbricksEnvironment.surveyScriptUrlString)";
|
||||
script.async = true;
|
||||
script.onload = () => loadSurvey();
|
||||
script.onerror = (error) => {
|
||||
@@ -110,6 +110,7 @@ private class WebViewData {
|
||||
let jsonData = try JSONSerialization.data(withJSONObject: data, options: [])
|
||||
return String(data: jsonData, encoding: .utf8)?.replacingOccurrences(of: "\\\"", with: "'")
|
||||
} catch {
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error(error.message)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -119,10 +119,12 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
|
||||
/// Happens when a survey is shown.
|
||||
case .onDisplayCreated:
|
||||
Formbricks.delegate?.onSurveyStarted()
|
||||
Formbricks.surveyManager?.onNewDisplay(surveyId: surveyId)
|
||||
|
||||
/// Happens when the user closes the survey view with the close button.
|
||||
case .onClose:
|
||||
Formbricks.delegate?.onSurveyClosed()
|
||||
Formbricks.surveyManager?.dismissSurveyWebView()
|
||||
|
||||
/// Happens when the survey wants to open an external link in the default browser.
|
||||
@@ -137,7 +139,9 @@ final class JsMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
}
|
||||
|
||||
} else {
|
||||
Formbricks.logger?.error("\(FormbricksSDKError(type: .invalidJavascriptMessage).message): \(message.body)")
|
||||
let error = FormbricksSDKError(type: .invalidJavascriptMessage)
|
||||
Formbricks.delegate?.onError(error)
|
||||
Formbricks.logger?.error("\(error.message): \(message.body)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user