Compare commits

...

18 Commits

Author SHA1 Message Date
victorvhs017
40fa7a69c0 fix: refactor clickable divs into buttons (#5489)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-04-25 08:13:15 +00:00
Anshuman Pandey
5eca30e513 fix: android sonarqube issues (#5503) 2025-04-25 06:53:39 +00:00
Piyush Gupta
4b78493782 fix: SAML flow jose package import (#5497) 2025-04-25 06:24:59 +00:00
Anshuman Pandey
2ce44b734f fix: stop timers on logout (#5498) 2025-04-24 12:56:28 +00:00
Anshuman Pandey
85d8f8c3ae fix: iOS sonarqube issues (#5494) 2025-04-24 11:19:52 +00:00
victorvhs017
3f16291137 fix: updated encryption algorithm and added sort function (#5485)
Co-authored-by: Piyush Gupta <piyushguptaa2z123@gmail.com>
2025-04-24 07:47:22 +00:00
victorvhs017
a5958d5653 chore: update sonar properties file (#5472) 2025-04-23 22:09:16 +02:00
victorvhs017
fdbdf8207a chore: add js-core to SonarQube check (#5466) 2025-04-23 22:08:31 +02:00
Piyush Gupta
630e5489ec feat: Implement v2 management api endpoint for contact attribute keys (#5316)
Co-authored-by: Victor Santos <victor@formbricks.com>
2025-04-23 15:48:18 +00:00
Anshuman Pandey
36943bb786 fix: android sdk callbacks, tweaks and fixes (#5487) 2025-04-23 13:37:22 +00:00
Dhruwang Jariwala
e1bbb0a10f fix: billing (#5483) 2025-04-23 09:54:08 +00:00
Piyush Jain
27da540846 chore: fix buckets and iam for staging env (#5475) 2025-04-23 08:24:45 +00:00
Piyush Jain
7d7f6ed04a chore(terraform): add valkey and rds for staging env (#5471) 2025-04-22 16:11:16 +00:00
Vijay
ff01bc342d fix: Some DoS (usage of regex) Sonar Security Hotspots (#5334) 2025-04-22 17:16:22 +02:00
Anshuman Pandey
cd8b40b569 fix: cleanup issue in iOS package (#5473) 2025-04-22 14:50:28 +00:00
Anshuman Pandey
31c742f7a8 fix: setLanguage and icon issue for the iOS SDK (#5470) 2025-04-22 13:18:28 +00:00
Dhruwang Jariwala
d6a7a2c21f fix: x button not visible when close on click outside is not allowed (#5464)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-04-22 07:13:04 +00:00
Dhruwang Jariwala
499ecab691 chore: update alpine version (#5465) 2025-04-22 06:48:52 +00:00
136 changed files with 5883 additions and 864 deletions

26
.github/copilot-instructions.md vendored Normal file
View 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.

View File

@@ -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",

View File

@@ -1,4 +1,4 @@
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
FROM node:22-alpine3.21 AS base
#
## step 1: Prune monorepo

View File

@@ -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();
});
});

View File

@@ -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>
) : (

View File

@@ -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));
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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));
});
});

View File

@@ -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>

View File

@@ -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");
});
});

View File

@@ -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 (

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -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");
});
});

View File

@@ -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>
))}

View File

@@ -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"]
);
});
});

View File

@@ -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 doesnt, push the one with others to the end
if (aHasOthers && !bHasOthers) return 1;
if (!aHasOthers && bHasOthers) return -1;
// if theyre “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>

View File

@@ -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
);
});
});

View File

@@ -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>

View File

@@ -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();
});
});

View File

@@ -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>

View File

@@ -1,6 +1,7 @@
"use client";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { SettingsId } from "@/modules/ui/components/settings-id";
import { useTranslate } from "@tolgee/react";
@@ -23,31 +24,15 @@ export const QuestionSummaryHeader = ({
}: HeadProps) => {
const { t } = useTranslate();
const questionType = getQuestionTypes(t).find((type) => type.id === questionSummary.question.type);
// formats the text to highlight specific parts of the text with slashes
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="space-y-2 px-4 pt-6 pb-5 md:px-6">
<div className={"align-center flex justify-between gap-4"}>
<h3 className="pb-1 text-lg font-semibold text-slate-900 md:text-xl">
{formatTextWithSlashes(
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"]
recallToHeadline(questionSummary.question.headline, survey, true, "default")["default"],
"@",
["text-lg"]
)}
</h3>
</div>

View File

@@ -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();
});
});

View File

@@ -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 && (

View File

@@ -1,11 +1,11 @@
"use client";
import { recallToHeadline } from "@/lib/utils/recall";
import { formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { getQuestionIcon } from "@/modules/survey/lib/questions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TimerIcon } from "lucide-react";
import { JSX } from "react";
import { TSurvey, TSurveyQuestionType, TSurveySummary } from "@formbricks/types/surveys/types";
interface SummaryDropOffsProps {
@@ -20,24 +20,6 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
return <Icon className="mt-[3px] h-5 w-5 shrink-0 text-slate-600" />;
};
const formatTextWithSlashes = (text: string): (string | JSX.Element)[] => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-lg">
@{part}
</span>
);
} else {
return part;
}
});
};
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="">
@@ -73,7 +55,9 @@ export const SummaryDropOffs = ({ dropOff, survey }: SummaryDropOffsProps) => {
survey,
true,
"default"
)["default"]
)["default"],
"@",
["text-lg"]
)}
</p>
</div>

View File

@@ -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);
});
});

View File

@@ -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}

View File

@@ -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");
});
});

View File

@@ -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>

View File

@@ -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
});
});

View File

@@ -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>

View File

@@ -22,6 +22,10 @@ export const GET = async (
return responses.unauthorizedResponse();
}
if (survey.type !== "link") {
return responses.badRequestResponse("Single use links are only available for link surveys");
}
if (!survey.singleUse || !survey.singleUse.enabled) {
return responses.badRequestResponse("Single use links are not enabled for this survey");
}

View File

@@ -0,0 +1,7 @@
import {
DELETE,
GET,
PUT,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route";
export { GET, PUT, DELETE };

View File

@@ -0,0 +1,3 @@
import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route";
export { GET, POST };

View File

@@ -7,7 +7,6 @@ import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUs
vi.mock("@/lib/crypto", () => ({
symmetricEncrypt: vi.fn(),
symmetricDecrypt: vi.fn(),
decryptAES128: vi.fn(),
}));
// Mock constants

View File

@@ -0,0 +1,59 @@
import { createCipheriv, randomBytes } from "crypto";
import { describe, expect, test, vi } from "vitest";
import {
generateLocalSignedUrl,
getHash,
symmetricDecrypt,
symmetricEncrypt,
validateLocalSignedUrl,
} from "./crypto";
vi.mock("./constants", () => ({ ENCRYPTION_KEY: "0".repeat(32) }));
const key = "0".repeat(32);
const plain = "hello";
describe("crypto", () => {
test("encrypt + decrypt roundtrip", () => {
const cipher = symmetricEncrypt(plain, key);
expect(symmetricDecrypt(cipher, key)).toBe(plain);
});
test("decrypt V2 GCM payload", () => {
const iv = randomBytes(16);
const bufKey = Buffer.from(key, "utf8");
const cipher = createCipheriv("aes-256-gcm", bufKey, iv);
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const tag = cipher.getAuthTag().toString("hex");
const payload = `${iv.toString("hex")}:${enc}:${tag}`;
expect(symmetricDecrypt(payload, key)).toBe(plain);
});
test("decrypt legacy (single-colon) payload", () => {
const iv = randomBytes(16);
const cipher = createCipheriv("aes256", Buffer.from(key, "utf8"), iv); // NOSONAR typescript:S5542 // We are testing backwards compatibility
let enc = cipher.update(plain, "utf8", "hex");
enc += cipher.final("hex");
const legacy = `${iv.toString("hex")}:${enc}`;
expect(symmetricDecrypt(legacy, key)).toBe(plain);
});
test("getHash returns a non-empty string", () => {
const h = getHash("abc");
expect(typeof h).toBe("string");
expect(h.length).toBeGreaterThan(0);
});
test("signed URL generation & validation", () => {
const { uuid, timestamp, signature } = generateLocalSignedUrl("f", "e", "t");
expect(uuid).toHaveLength(32);
expect(typeof timestamp).toBe("number");
expect(typeof signature).toBe("string");
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, signature, key)).toBe(true);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp, "bad", key)).toBe(false);
expect(validateLocalSignedUrl(uuid, "f", "e", "t", timestamp - 1000 * 60 * 6, signature, key)).toBe(
false
);
});
});

View File

@@ -1,11 +1,12 @@
import crypto from "crypto";
import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "crypto";
import { logger } from "@formbricks/logger";
import { ENCRYPTION_KEY } from "./constants";
const ALGORITHM = "aes256";
const ALGORITHM_V1 = "aes256";
const ALGORITHM_V2 = "aes-256-gcm";
const INPUT_ENCODING = "utf8";
const OUTPUT_ENCODING = "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY!.length === 32 ? "latin1" : "hex";
const BUFFER_ENCODING = ENCRYPTION_KEY.length === 32 ? "latin1" : "hex";
const IV_LENGTH = 16; // AES blocksize
/**
@@ -17,15 +18,12 @@ const IV_LENGTH = 16; // AES blocksize
*/
export const symmetricEncrypt = (text: string, key: string) => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = crypto.randomBytes(IV_LENGTH);
// @ts-ignore -- the package needs to be built
const cipher = crypto.createCipheriv(ALGORITHM, _key, iv);
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM_V2, _key, iv);
let ciphered = cipher.update(text, INPUT_ENCODING, OUTPUT_ENCODING);
ciphered += cipher.final(OUTPUT_ENCODING);
const ciphertext = iv.toString(OUTPUT_ENCODING) + ":" + ciphered;
return ciphertext;
const tag = cipher.getAuthTag().toString(OUTPUT_ENCODING);
return `${iv.toString(OUTPUT_ENCODING)}:${ciphered}:${tag}`;
};
/**
@@ -33,38 +31,68 @@ export const symmetricEncrypt = (text: string, key: string) => {
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
export const symmetricDecrypt = (text: string, key: string) => {
const symmetricDecryptV1 = (text: string, key: string): string => {
const _key = Buffer.from(key, BUFFER_ENCODING);
const components = text.split(":");
const iv_from_ciphertext = Buffer.from(components.shift() || "", OUTPUT_ENCODING);
// @ts-ignore -- the package needs to be built
const decipher = crypto.createDecipheriv(ALGORITHM, _key, iv_from_ciphertext);
const iv_from_ciphertext = Buffer.from(components.shift() ?? "", OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V1, _key, iv_from_ciphertext);
let deciphered = decipher.update(components.join(":"), OUTPUT_ENCODING, INPUT_ENCODING);
deciphered += decipher.final(INPUT_ENCODING);
return deciphered;
};
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
/**
*
* @param text Value to decrypt
* @param key Key used to decrypt value must be 32 bytes for AES256 encryption algorithm
*/
// create an aes128 encryption function
export const encryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createCipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let encrypted = cipher.update(data, "utf-8", "hex");
encrypted += cipher.final("hex");
return encrypted;
};
// create an aes128 decryption function
export const decryptAES128 = (encryptionKey: string, data: string): string => {
// @ts-ignore -- the package needs to be built
const cipher = createDecipheriv("aes-128-ecb", Buffer.from(encryptionKey, "base64"), "");
let decrypted = cipher.update(data, "hex", "utf-8");
decrypted += cipher.final("utf-8");
const symmetricDecryptV2 = (text: string, key: string): string => {
// split into [ivHex, encryptedHex, tagHex]
const [ivHex, encryptedHex, tagHex] = text.split(":");
const _key = Buffer.from(key, BUFFER_ENCODING);
const iv = Buffer.from(ivHex, OUTPUT_ENCODING);
const decipher = createDecipheriv(ALGORITHM_V2, _key, iv);
decipher.setAuthTag(Buffer.from(tagHex, OUTPUT_ENCODING));
let decrypted = decipher.update(encryptedHex, OUTPUT_ENCODING, INPUT_ENCODING);
decrypted += decipher.final(INPUT_ENCODING);
return decrypted;
};
/**
* Decrypts an encrypted payload, automatically handling multiple encryption versions.
*
* If the payload contains exactly one “:”, it is treated as a legacy V1 format
* and `symmetricDecryptV1` is invoked. Otherwise, it attempts a V2 GCM decryption
* via `symmetricDecryptV2`, falling back to V1 on failure (e.g., authentication
* errors or bad formats).
*
* @param payload - The encrypted string to decrypt.
* @param key - The secret key used for decryption.
* @returns The decrypted plaintext.
*/
export function symmetricDecrypt(payload: string, key: string): string {
// If it's clearly V1 (only one “:”), skip straight to V1
if (payload.split(":").length === 2) {
return symmetricDecryptV1(payload, key);
}
// Otherwise try GCM first, then fall back to CBC
try {
return symmetricDecryptV2(payload, key);
} catch (err) {
logger.warn("AES-GCM decryption failed; refusing to fall back to insecure CBC", err);
throw err;
}
}
export const getHash = (key: string): string => createHash("sha256").update(key).digest("hex");
export const generateLocalSignedUrl = (
fileName: string,
environmentId: string,
@@ -73,7 +101,7 @@ export const generateLocalSignedUrl = (
const uuid = randomBytes(16).toString("hex");
const timestamp = Date.now();
const data = `${uuid}:${fileName}:${environmentId}:${fileType}:${timestamp}`;
const signature = createHmac("sha256", ENCRYPTION_KEY!).update(data).digest("hex");
const signature = createHmac("sha256", ENCRYPTION_KEY).update(data).digest("hex");
return { signature, uuid, timestamp };
};

View File

@@ -0,0 +1,143 @@
import {
mockLanguage,
mockLanguageId,
mockLanguageInput,
mockLanguageUpdate,
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { projectCache } from "@/lib/project/cache";
import { getProject } from "@/lib/project/service";
import { surveyCache } from "@/lib/survey/cache";
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { TProject } from "@formbricks/types/project";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
// stub out project/service and caches
vi.mock("@/lib/project/service", () => ({
getProject: vi.fn(),
}));
vi.mock("@/lib/project/cache", () => ({
projectCache: { revalidate: vi.fn() },
}));
vi.mock("@/lib/survey/cache", () => ({
surveyCache: { revalidate: vi.fn() },
}));
const fakeProject = {
id: mockProjectId,
environments: [{ id: "env1" }, { id: "env2" }],
} as TProject;
const testInputValidation = async (
service: (projectId: string, ...functionArgs: any[]) => Promise<any>,
...args: any[]
): Promise<void> => {
test("throws ValidationError on bad input", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};
describe("createLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path creates a new Language", async () => {
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
const result = await createLanguage(mockProjectId, mockLanguageInput);
expect(result).toEqual(mockLanguage);
// projectCache.revalidate called for each env
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
});
describe("sad path", () => {
testInputValidation(createLanguage, "bad-id", {});
test("throws DatabaseError when PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.create).mockRejectedValue(err);
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError);
});
});
});
describe("updateLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path updates a language", async () => {
const mockUpdatedLanguageWithSurveyLanguage = {
...mockUpdatedLanguage,
surveyLanguages: [
{
id: "surveyLanguageId",
},
],
};
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguageWithSurveyLanguage);
const result = await updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate);
expect(result).toEqual(mockUpdatedLanguage);
// caches revalidated
expect(projectCache.revalidate).toHaveBeenCalled();
expect(surveyCache.revalidate).toHaveBeenCalled();
});
describe("sad path", () => {
testInputValidation(updateLanguage, "bad-id", mockLanguageId, {});
test("throws DatabaseError on PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.update).mockRejectedValue(err);
await expect(updateLanguage(mockProjectId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
DatabaseError
);
});
});
});
describe("deleteLanguage", () => {
beforeEach(() => {
vi.mocked(getProject).mockResolvedValue(fakeProject);
});
test("happy path deletes a language", async () => {
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
const result = await deleteLanguage(mockLanguageId, mockProjectId);
expect(result).toEqual(mockLanguage);
expect(projectCache.revalidate).toHaveBeenCalledTimes(2);
});
describe("sad path", () => {
testInputValidation(deleteLanguage, "bad-id", mockProjectId);
test("throws DatabaseError on PrismaKnownRequestError", async () => {
const err = new Prisma.PrismaClientKnownRequestError("dup", {
code: "P2002",
clientVersion: "1",
});
vi.mocked(prisma.language.delete).mockRejectedValue(err);
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError);
});
});
});

View File

@@ -1,136 +0,0 @@
import {
mockEnvironmentId,
mockLanguage,
mockLanguageId,
mockLanguageInput,
mockLanguageUpdate,
mockProjectId,
mockUpdatedLanguage,
} from "./__mocks__/data.mock";
import { Prisma } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { createLanguage, deleteLanguage, updateLanguage } from "../service";
vi.mock("@formbricks/database", () => ({
prisma: {
language: {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
const testInputValidation = async (service: Function, ...args: any[]): Promise<void> => {
test("it should throw a ValidationError if the inputs are invalid", async () => {
await expect(service(...args)).rejects.toThrow(ValidationError);
});
};
describe("Tests for createLanguage service", () => {
describe("Happy Path", () => {
test("Creates a new Language", async () => {
vi.mocked(prisma.language.create).mockResolvedValue(mockLanguage);
const language = await createLanguage(mockProjectId, mockLanguageInput);
expect(language).toEqual(mockLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(createLanguage, "123");
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.create).mockRejectedValue(errToThrow);
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.create).mockRejectedValue(new Error(mockErrorMessage));
await expect(createLanguage(mockProjectId, mockLanguageInput)).rejects.toThrow(Error);
});
});
});
describe("Tests for updateLanguage Service", () => {
describe("Happy Path", () => {
test("Updates a language", async () => {
vi.mocked(prisma.language.update).mockResolvedValue(mockUpdatedLanguage);
const language = await updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate);
expect(language).toEqual(mockUpdatedLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(updateLanguage, "123", "123");
test("Throws DatabaseError on PrismaClientKnownRequestError", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.update).mockRejectedValue(errToThrow);
await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
DatabaseError
);
});
test("Throws a generic Error for other unexpected issues", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.update).mockRejectedValue(new Error(mockErrorMessage));
await expect(updateLanguage(mockEnvironmentId, mockLanguageId, mockLanguageUpdate)).rejects.toThrow(
Error
);
});
});
});
describe("Tests for deleteLanguage", () => {
describe("Happy Path", () => {
test("Deletes a Language", async () => {
vi.mocked(prisma.language.delete).mockResolvedValue(mockLanguage);
const language = await deleteLanguage(mockLanguageId, mockProjectId);
expect(language).toEqual(mockLanguage);
});
});
describe("Sad Path", () => {
testInputValidation(deleteLanguage, "123");
test("Throws DatabaseError on PrismaClientKnownRequestError occurrence", async () => {
const mockErrorMessage = "Mock error message";
const errToThrow = new Prisma.PrismaClientKnownRequestError(mockErrorMessage, {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.language.delete).mockRejectedValue(errToThrow);
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(DatabaseError);
});
test("Throws a generic Error for other exceptions", async () => {
const mockErrorMessage = "Mock error message";
vi.mocked(prisma.language.delete).mockRejectedValue(new Error(mockErrorMessage));
await expect(deleteLanguage(mockLanguageId, mockProjectId)).rejects.toThrow(Error);
});
});
});

View File

@@ -3,6 +3,7 @@ import { cache } from "@/lib/cache";
import { BILLING_LIMITS, ITEMS_PER_PAGE, PROJECT_FEATURE_KEYS } from "@/lib/constants";
import { getProjects } from "@/lib/project/service";
import { updateUser } from "@/lib/user/service";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
@@ -337,19 +338,8 @@ export const getMonthlyOrganizationResponseCount = reactCache(
throw new ResourceNotFoundError("Organization", organizationId);
}
// Determine the start date based on the plan type
let startDate: Date;
if (organization.billing.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!organization.billing.periodStart) {
throw new Error("Organization billing period start is not set");
}
startDate = organization.billing.periodStart;
}
// Use the utility function to calculate the start date
const startDate = getBillingPeriodStartDate(organization.billing);
// Get all environment IDs for the organization
const projects = await getProjects(organizationId);

View File

@@ -0,0 +1,176 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { getBillingPeriodStartDate } from "./billing";
describe("getBillingPeriodStartDate", () => {
let originalDate: DateConstructor;
beforeEach(() => {
// Store the original Date constructor
originalDate = global.Date;
});
afterEach(() => {
// Restore the original Date constructor
global.Date = originalDate;
vi.useRealTimers();
});
test("returns first day of month for free plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
const organization = {
billing: {
plan: "free",
periodStart: new Date("2023-01-15"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For free plans, should return first day of current month
expect(result).toEqual(new Date(2023, 2, 1));
});
test("returns correct date for monthly plans", () => {
// Mock the current date to be 2023-03-15
vi.setSystemTime(new Date(2023, 2, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-02-10"),
period: "monthly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// For monthly plans, should return periodStart directly
expect(result).toEqual(new Date("2023-02-10"));
});
test("returns current month's subscription day for yearly plans when today is after subscription day", () => {
// Mock the current date to be March 20, 2023
vi.setSystemTime(new Date(2023, 2, 20));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 15, 2023 (same day in current month)
expect(result).toEqual(new Date(2023, 2, 15));
});
test("returns previous month's subscription day for yearly plans when today is before subscription day", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-05-15"), // Original subscription on 15th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 15, 2023 (same day in previous month)
expect(result).toEqual(new Date(2023, 1, 15));
});
test("handles subscription day that doesn't exist in current month (February edge case)", () => {
// Mock the current date to be February 15, 2023
vi.setSystemTime(new Date(2023, 1, 15));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return January 31, 2023 (previous month's subscription day)
// since today (Feb 15) is less than the subscription day (31st)
expect(result).toEqual(new Date(2023, 0, 31));
});
test("handles subscription day that doesn't exist in previous month (February to March transition)", () => {
// Mock the current date to be March 10, 2023
vi.setSystemTime(new Date(2023, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 28, 2023 (last day of February)
// since February 2023 doesn't have a 30th day
expect(result).toEqual(new Date(2023, 1, 28));
});
test("handles subscription day that doesn't exist in previous month (leap year)", () => {
// Mock the current date to be March 10, 2024 (leap year)
vi.setSystemTime(new Date(2024, 2, 10));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2023-01-30"), // Original subscription on 30th
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return February 29, 2024 (last day of February in leap year)
expect(result).toEqual(new Date(2024, 1, 29));
});
test("handles current month with fewer days than subscription day", () => {
// Mock the current date to be April 25, 2023 (April has 30 days)
vi.setSystemTime(new Date(2023, 3, 25));
const organization = {
billing: {
plan: "scale",
periodStart: new Date("2022-01-31"), // Original subscription on 31st
period: "yearly",
},
};
const result = getBillingPeriodStartDate(organization.billing);
// Should return March 31, 2023 (since today is before April's adjusted subscription day)
expect(result).toEqual(new Date(2023, 2, 31));
});
test("throws error when periodStart is not set for non-free plans", () => {
const organization = {
billing: {
plan: "scale",
periodStart: null,
period: "monthly",
},
};
expect(() => {
getBillingPeriodStartDate(organization.billing);
}).toThrow("billing period start is not set");
});
});

View File

@@ -0,0 +1,54 @@
import { TOrganizationBilling } from "@formbricks/types/organizations";
// Function to calculate billing period start date based on organization plan and billing period
export const getBillingPeriodStartDate = (billing: TOrganizationBilling): Date => {
const now = new Date();
if (billing.plan === "free") {
// For free plans, use the first day of the current calendar month
return new Date(now.getFullYear(), now.getMonth(), 1);
} 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);
// 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 => {
// Create a date for the first day of the next month, then subtract one day
return new Date(year, month + 1, 0).getDate();
};
// Calculate the adjusted day for the current month
const lastDayOfCurrentMonth = getLastDayOfMonth(now.getFullYear(), now.getMonth());
const adjustedCurrentMonthDay = Math.min(subscriptionDay, lastDayOfCurrentMonth);
// Calculate the current month's adjusted subscription date
const currentMonthSubscriptionDate = new Date(now.getFullYear(), now.getMonth(), adjustedCurrentMonthDay);
// If today is before the subscription day in the current month (or its adjusted equivalent),
// we should use the previous month's subscription day as our start date
if (now.getDate() < adjustedCurrentMonthDay) {
// Calculate previous month and year
const prevMonth = now.getMonth() === 0 ? 11 : now.getMonth() - 1;
const prevYear = now.getMonth() === 0 ? now.getFullYear() - 1 : now.getFullYear();
// Calculate the adjusted day for the previous month
const lastDayOfPreviousMonth = getLastDayOfMonth(prevYear, prevMonth);
const adjustedPreviousMonthDay = Math.min(subscriptionDay, lastDayOfPreviousMonth);
// Return the adjusted previous month date
return new Date(prevYear, prevMonth, adjustedPreviousMonthDay);
} else {
return currentMonthSubscriptionDate;
}
} else if (billing.period === "monthly" && billing.periodStart) {
// For monthly plans with a periodStart, use that date
return new Date(billing.periodStart);
} else {
// For other plans, use the periodStart from billing
if (!billing.periodStart) {
throw new Error("billing period start is not set");
}
return new Date(billing.periodStart);
}
};

View File

@@ -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>
)}

View File

@@ -47,8 +47,5 @@ describe("isSubmissionTimeMoreThan5Minutes", () => {
const currentTime = new Date();
const recentTime = new Date(currentTime.getTime() - 4 * 60 * 1000); // 4 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(recentTime)).toBe(false);
const exact5Minutes = new Date(currentTime.getTime() - 5 * 60 * 1000); // exactly 5 minutes ago
expect(isSubmissionTimeMoreThan5Minutes(exact5Minutes)).toBe(false);
});
});

View File

@@ -21,11 +21,19 @@ export type ExtendedSchemas = {
};
// Define a type that returns separate keys for each input type.
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = {
body?: S extends { body: z.ZodObject<any> } ? z.infer<S["body"]> : undefined;
query?: S extends { query: z.ZodObject<any> } ? z.infer<S["query"]> : undefined;
params?: S extends { params: z.ZodObject<any> } ? z.infer<S["params"]> : undefined;
};
// It uses mapped types to create a new type based on the input schemas.
// It checks if each schema is defined and if it is a ZodObject, then infers the type from it.
// It also uses conditional types to ensure that the keys are only included if the schema is defined and valid.
// This allows for more flexibility and type safety when working with the input schemas.
export type ParsedSchemas<S extends ExtendedSchemas | undefined> = S extends object
? {
[K in keyof S as NonNullable<S[K]> extends z.ZodObject<any> ? K : never]: NonNullable<
S[K]
> extends z.ZodObject<any>
? z.infer<NonNullable<S[K]>>
: never;
}
: {};
export const apiWrapper = async <S extends ExtendedSchemas>({
request,

View File

@@ -260,6 +260,34 @@ const successResponse = ({
);
};
export const createdResponse = ({
data,
meta,
cors = false,
cache = "private, no-store",
}: {
data: Object;
meta?: Record<string, unknown>;
cors?: boolean;
cache?: string;
}) => {
const headers = {
...(cors && corsHeaders),
"Cache-Control": cache,
};
return Response.json(
{
data,
meta,
} as ApiSuccessResponse,
{
status: 201,
headers,
}
);
};
export const multiStatusResponse = ({
data,
meta,
@@ -298,5 +326,6 @@ export const responses = {
tooManyRequestsResponse,
internalServerErrorResponse,
successResponse,
createdResponse,
multiStatusResponse,
};

View File

@@ -120,7 +120,7 @@ describe("API Responses", () => {
});
test("include CORS headers when cors is true", () => {
const res = responses.unprocessableEntityResponse({ cors: true });
const res = responses.unprocessableEntityResponse({ cors: true, details: [] });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
@@ -182,4 +182,38 @@ describe("API Responses", () => {
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("createdResponse", () => {
test("return a success response with the provided data", async () => {
const data = { foo: "bar" };
const meta = { page: 1 };
const res = responses.createdResponse({ data, meta });
expect(res.status).toBe(201);
const body = await res.json();
expect(body.data).toEqual(data);
expect(body.meta).toEqual(meta);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.createdResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
describe("multiStatusResponse", () => {
test("return a 207 response with the provided data", async () => {
const data = { foo: "bar" };
const res = responses.multiStatusResponse({ data });
expect(res.status).toBe(207);
const body = await res.json();
expect(body.data).toEqual(data);
});
test("include CORS headers when cors is true", () => {
const data = { foo: "bar" };
const res = responses.multiStatusResponse({ data, cors: true });
expect(res.headers.get("Access-Control-Allow-Origin")).toBe("*");
});
});
});

View File

@@ -0,0 +1,183 @@
import { cache } from "@/lib/cache";
import { contactCache } from "@/lib/cache/contact";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKey = reactCache(async (contactAttributeKeyId: string) =>
cache(
async (): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const contactAttributeKey = await prisma.contactAttributeKey.findUnique({
where: {
id: contactAttributeKeyId,
},
});
if (!contactAttributeKey) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
return ok(contactAttributeKey);
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
},
[`management-getContactAttributeKey-${contactAttributeKeyId}`],
{
tags: [contactAttributeKeyCache.tag.byId(contactAttributeKeyId)],
}
)()
);
export const updateContactAttributeKey = async (
contactAttributeKeyId: string,
contactAttributeKeyInput: TContactAttributeKeyUpdateSchema
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const updatedKey = await prisma.contactAttributeKey.update({
where: {
id: contactAttributeKeyId,
},
data: contactAttributeKeyInput,
});
const associatedContactAttributes = await prisma.contactAttribute.findMany({
where: {
attributeKeyId: updatedKey.id,
},
select: {
id: true,
contactId: true,
},
});
contactAttributeKeyCache.revalidate({
id: contactAttributeKeyId,
environmentId: updatedKey.environmentId,
key: updatedKey.key,
});
contactAttributeCache.revalidate({
key: updatedKey.key,
environmentId: updatedKey.environmentId,
});
contactCache.revalidate({
environmentId: updatedKey.environmentId,
});
associatedContactAttributes.forEach((contactAttribute) => {
contactAttributeCache.revalidate({
contactId: contactAttribute.contactId,
});
contactCache.revalidate({
id: contactAttribute.contactId,
});
});
return ok(updatedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: `Contact attribute key with "${contactAttributeKeyInput.key}" already exists`,
},
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};
export const deleteContactAttributeKey = async (
contactAttributeKeyId: string
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
try {
const deletedKey = await prisma.contactAttributeKey.delete({
where: {
id: contactAttributeKeyId,
},
});
const associatedContactAttributes = await prisma.contactAttribute.findMany({
where: {
attributeKeyId: deletedKey.id,
},
select: {
id: true,
contactId: true,
},
});
contactAttributeKeyCache.revalidate({
id: contactAttributeKeyId,
environmentId: deletedKey.environmentId,
key: deletedKey.key,
});
contactAttributeCache.revalidate({
key: deletedKey.key,
environmentId: deletedKey.environmentId,
});
contactCache.revalidate({
environmentId: deletedKey.environmentId,
});
associatedContactAttributes.forEach((contactAttribute) => {
contactAttributeCache.revalidate({
contactId: contactAttribute.contactId,
});
contactCache.revalidate({
id: contactAttribute.contactId,
});
});
return ok(deletedKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};

View File

@@ -1,4 +1,8 @@
import { ZContactAttributeKeyInput } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import {
ZContactAttributeKeyIdSchema,
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { z } from "zod";
import { ZodOpenApiOperationObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
@@ -9,7 +13,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Gets a contact attribute key from the database.",
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
id: ZContactAttributeKeyIdSchema,
}),
},
tags: ["Management API > Contact Attribute Keys"],
@@ -18,29 +22,7 @@ export const getContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Contact attribute key retrieved successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
},
},
},
},
};
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeId: z.string().cuid2(),
}),
},
responses: {
"200": {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
@@ -54,7 +36,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
contactAttributeKeyId: z.string().cuid2(),
id: ZContactAttributeKeyIdSchema,
}),
},
requestBody: {
@@ -62,7 +44,7 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "The contact attribute key to update",
content: {
"application/json": {
schema: ZContactAttributeKeyInput,
schema: ZContactAttributeKeyUpdateSchema,
},
},
},
@@ -71,7 +53,29 @@ export const updateContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
description: "Contact attribute key updated successfully.",
content: {
"application/json": {
schema: ZContactAttributeKey,
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
},
};
export const deleteContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
operationId: "deleteContactAttributeKey",
summary: "Delete a contact attribute key",
description: "Deletes a contact attribute key from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
path: z.object({
id: ZContactAttributeKeyIdSchema,
}),
},
responses: {
"200": {
description: "Contact attribute key deleted successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
},
},
},

View File

@@ -0,0 +1,222 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { TContactAttributeKeyUpdateSchema } from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import {
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
} from "../contact-attribute-key";
// Mock dependencies
vi.mock("@formbricks/database", () => ({
prisma: {
contactAttributeKey: {
findUnique: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
},
contactAttribute: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: {
tag: {
byId: () => "mockTag",
},
revalidate: vi.fn(),
},
}));
// Mock data
const mockContactAttributeKey: ContactAttributeKey = {
id: "cak123",
key: "email",
name: "Email",
description: "User's email address",
environmentId: "env123",
isUnique: true,
type: "default",
createdAt: new Date(),
updatedAt: new Date(),
};
const mockUpdateInput: TContactAttributeKeyUpdateSchema = {
key: "email",
name: "Email Address",
description: "User's verified email address",
};
const prismaNotFoundError = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
const prismaUniqueConstraintError = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
describe("getContactAttributeKey", () => {
test("returns ok if contact attribute key is found", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(mockContactAttributeKey);
const result = await getContactAttributeKey("cak123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockContactAttributeKey);
}
});
test("returns err if contact attribute key not found", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockResolvedValueOnce(null);
const result = await getContactAttributeKey("cak999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns err on Prisma error", async () => {
vi.mocked(prisma.contactAttributeKey.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getContactAttributeKey("error");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "DB error" }],
});
}
});
});
describe("updateContactAttributeKey", () => {
test("returns ok on successful update", async () => {
const updatedKey = { ...mockContactAttributeKey, ...mockUpdateInput };
vi.mocked(prisma.contactAttributeKey.update).mockResolvedValueOnce(updatedKey);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([
{ id: "contact1", contactId: "contact1" },
{ id: "contact2", contactId: "contact2" },
]);
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(updatedKey);
}
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
id: "cak123",
environmentId: mockContactAttributeKey.environmentId,
key: mockUpdateInput.key,
});
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateContactAttributeKey("cak999", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns conflict error if key already exists", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(prismaUniqueConstraintError);
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "conflict",
details: [
{ field: "contactAttributeKey", issue: 'Contact attribute key with "email" already exists' },
],
});
}
});
test("returns internal_server_error if other error occurs", async () => {
vi.mocked(prisma.contactAttributeKey.update).mockRejectedValueOnce(new Error("Unknown error"));
const result = await updateContactAttributeKey("cak123", mockUpdateInput);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "Unknown error" }],
});
}
});
});
describe("deleteContactAttributeKey", () => {
test("returns ok on successful delete", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockResolvedValueOnce(mockContactAttributeKey);
vi.mocked(prisma.contactAttribute.findMany).mockResolvedValueOnce([
{ id: "contact1", contactId: "contact1" },
{ id: "contact2", contactId: "contact2" },
]);
const result = await deleteContactAttributeKey("cak123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockContactAttributeKey);
}
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
id: "cak123",
environmentId: mockContactAttributeKey.environmentId,
key: mockContactAttributeKey.key,
});
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(prismaNotFoundError);
const result = await deleteContactAttributeKey("cak999");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
});
test("returns internal_server_error on other errors", async () => {
vi.mocked(prisma.contactAttributeKey.delete).mockRejectedValueOnce(new Error("Delete error"));
const result = await deleteContactAttributeKey("cak123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: "Delete error" }],
});
}
});
});

View File

@@ -0,0 +1,131 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
deleteContactAttributeKey,
getContactAttributeKey,
updateContactAttributeKey,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/lib/contact-attribute-key";
import {
ZContactAttributeKeyIdSchema,
ZContactAttributeKeyUpdateSchema,
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/types/contact-attribute-keys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
import { z } from "zod";
export const GET = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
return responses.successResponse(res);
},
});
export const PUT = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
body: ZContactAttributeKeyUpdateSchema,
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params, body } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "PUT")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot update unique contact attribute key" }],
});
}
const updatedContactAttributeKey = await updateContactAttributeKey(params.contactAttributeKeyId, body);
if (!updatedContactAttributeKey.ok) {
return handleApiError(request, updatedContactAttributeKey.error);
}
return responses.successResponse(updatedContactAttributeKey);
},
});
export const DELETE = async (
request: NextRequest,
props: { params: Promise<{ contactAttributeKeyId: string }> }
) =>
authenticatedApiClient({
request,
schemas: {
params: z.object({ contactAttributeKeyId: ZContactAttributeKeyIdSchema }),
},
externalParams: props.params,
handler: async ({ authentication, parsedInput }) => {
const { params } = parsedInput;
const res = await getContactAttributeKey(params.contactAttributeKeyId);
if (!res.ok) {
return handleApiError(request, res.error);
}
if (!hasPermission(authentication.environmentPermissions, res.data.environmentId, "DELETE")) {
return handleApiError(request, {
type: "unauthorized",
details: [{ field: "environment", issue: "unauthorized" }],
});
}
if (res.data.isUnique) {
return handleApiError(request, {
type: "bad_request",
details: [{ field: "contactAttributeKey", issue: "cannot delete unique contact attribute key" }],
});
}
const deletedContactAttributeKey = await deleteContactAttributeKey(params.contactAttributeKeyId);
if (!deletedContactAttributeKey.ok) {
return handleApiError(request, deletedContactAttributeKey.error);
}
return responses.successResponse(deletedContactAttributeKey);
},
});

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZContactAttributeKeyIdSchema = z
.string()
.cuid2()
.openapi({
ref: "contactAttributeKeyId",
description: "The ID of the contact attribute key",
param: {
name: "id",
in: "path",
},
});
export const ZContactAttributeKeyUpdateSchema = ZContactAttributeKey.pick({
name: true,
description: true,
key: true,
}).openapi({
ref: "contactAttributeKeyUpdate",
description: "A contact attribute key to update.",
});
export type TContactAttributeKeyUpdateSchema = z.infer<typeof ZContactAttributeKeyUpdateSchema>;

View File

@@ -0,0 +1,105 @@
import { cache } from "@/lib/cache";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import { getContactAttributeKeysQuery } from "@/modules/api/v2/management/contact-attribute-keys/lib/utils";
import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { ApiResponseWithMeta } from "@/modules/api/v2/types/api-success";
import { ContactAttributeKey, Prisma } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
export const getContactAttributeKeys = reactCache(
async (environmentIds: string[], params: TGetContactAttributeKeysFilter) =>
cache(
async (): Promise<Result<ApiResponseWithMeta<ContactAttributeKey[]>, ApiErrorResponseV2>> => {
try {
const query = getContactAttributeKeysQuery(environmentIds, params);
const [keys, count] = await prisma.$transaction([
prisma.contactAttributeKey.findMany({
...query,
}),
prisma.contactAttributeKey.count({
where: query.where,
}),
]);
return ok({ data: keys, meta: { total: count, limit: params.limit, offset: params.skip } });
} catch (error) {
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKeys", issue: error.message }],
});
}
},
[`management-getContactAttributeKeys-${environmentIds.join(",")}-${JSON.stringify(params)}`],
{
tags: environmentIds.map((environmentId) =>
contactAttributeKeyCache.tag.byEnvironmentId(environmentId)
),
}
)()
);
export const createContactAttributeKey = async (
contactAttributeKey: TContactAttributeKeyInput
): Promise<Result<ContactAttributeKey, ApiErrorResponseV2>> => {
const { environmentId, name, description, key } = contactAttributeKey;
try {
const prismaData: Prisma.ContactAttributeKeyCreateInput = {
environment: {
connect: {
id: environmentId,
},
},
name,
description,
key,
};
const createdContactAttributeKey = await prisma.contactAttributeKey.create({
data: prismaData,
});
contactAttributeKeyCache.revalidate({
environmentId: createdContactAttributeKey.environmentId,
key: createdContactAttributeKey.key,
});
return ok(createdContactAttributeKey);
} catch (error) {
if (error instanceof PrismaClientKnownRequestError) {
if (
error.code === PrismaErrorType.RecordDoesNotExist ||
error.code === PrismaErrorType.RelatedRecordDoesNotExist
) {
return err({
type: "not_found",
details: [{ field: "contactAttributeKey", issue: "not found" }],
});
}
if (error.code === PrismaErrorType.UniqueConstraintViolation) {
return err({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: `Contact attribute key with "${contactAttributeKey.key}" already exists`,
},
],
});
}
}
return err({
type: "internal_server_error",
details: [{ field: "contactAttributeKey", issue: error.message }],
});
}
};

View File

@@ -8,9 +8,9 @@ import {
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { z } from "zod";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/types/contact-attribute-key";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
operationId: "getContactAttributeKeys",
@@ -18,14 +18,14 @@ export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
description: "Gets contact attribute keys from the database.",
tags: ["Management API > Contact Attribute Keys"],
requestParams: {
query: ZGetContactAttributeKeysFilter,
query: ZGetContactAttributeKeysFilter.sourceType(),
},
responses: {
"200": {
description: "Contact attribute keys retrieved successfully.",
content: {
"application/json": {
schema: z.array(ZContactAttributeKey),
schema: responseWithMetaSchema(makePartialSchema(ZContactAttributeKey)),
},
},
},
@@ -49,6 +49,11 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
responses: {
"201": {
description: "Contact attribute key created successfully.",
content: {
"application/json": {
schema: makePartialSchema(ZContactAttributeKey),
},
},
},
},
};

View File

@@ -0,0 +1,166 @@
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
import {
TContactAttributeKeyInput,
TGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { ContactAttributeKey } from "@prisma/client";
import { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { createContactAttributeKey, getContactAttributeKeys } from "../contact-attribute-key";
vi.mock("@formbricks/database", () => ({
prisma: {
$transaction: vi.fn(),
contactAttributeKey: {
findMany: vi.fn(),
count: vi.fn(),
create: vi.fn(),
},
},
}));
vi.mock("@/lib/cache/contact-attribute-key", () => ({
contactAttributeKeyCache: {
revalidate: vi.fn(),
tag: {
byEnvironmentId: vi.fn(),
},
},
}));
describe("getContactAttributeKeys", () => {
const environmentIds = ["env1", "env2"];
const params: TGetContactAttributeKeysFilter = {
limit: 10,
skip: 0,
order: "asc",
sortBy: "createdAt",
};
const fakeContactAttributeKeys = [
{ id: "key1", environmentId: "env1", name: "Key One", key: "keyOne" },
{ id: "key2", environmentId: "env1", name: "Key Two", key: "keyTwo" },
];
const count = fakeContactAttributeKeys.length;
test("returns ok response with contact attribute keys and meta", async () => {
vi.mocked(prisma.$transaction).mockResolvedValueOnce([fakeContactAttributeKeys, count]);
const result = await getContactAttributeKeys(environmentIds, params);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.data).toEqual(fakeContactAttributeKeys);
expect(result.data.meta).toEqual({
total: count,
limit: params.limit,
offset: params.skip,
});
}
});
test("returns error when prisma.$transaction throws", async () => {
vi.mocked(prisma.$transaction).mockRejectedValueOnce(new Error("Test error"));
const result = await getContactAttributeKeys(environmentIds, params);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error?.type).toEqual("internal_server_error");
}
});
});
describe("createContactAttributeKey", () => {
const inputContactAttributeKey: TContactAttributeKeyInput = {
environmentId: "env1",
name: "New Contact Attribute Key",
key: "newKey",
description: "Description for new key",
};
const createdContactAttributeKey: ContactAttributeKey = {
id: "key100",
environmentId: inputContactAttributeKey.environmentId,
name: inputContactAttributeKey.name,
key: inputContactAttributeKey.key,
description: inputContactAttributeKey.description,
isUnique: false,
type: "custom",
createdAt: new Date(),
updatedAt: new Date(),
};
test("creates a contact attribute key and revalidates cache", async () => {
vi.mocked(prisma.contactAttributeKey.create).mockResolvedValueOnce(createdContactAttributeKey);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(prisma.contactAttributeKey.create).toHaveBeenCalled();
expect(contactAttributeKeyCache.revalidate).toHaveBeenCalledWith({
environmentId: createdContactAttributeKey.environmentId,
key: createdContactAttributeKey.key,
});
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(createdContactAttributeKey);
}
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(new Error("Creation failed"));
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
}
});
test("returns conflict error when key already exists", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.UniqueConstraintViolation,
clientVersion: "0.0.1",
});
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "conflict",
details: [
{
field: "contactAttributeKey",
issue: 'Contact attribute key with "newKey" already exists',
},
],
});
}
});
test("returns not found error when related record does not exist", async () => {
const errToThrow = new PrismaClientKnownRequestError("Mock error message", {
code: PrismaErrorType.RelatedRecordDoesNotExist,
clientVersion: "0.0.1",
});
vi.mocked(prisma.contactAttributeKey.create).mockRejectedValueOnce(errToThrow);
const result = await createContactAttributeKey(inputContactAttributeKey);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toStrictEqual({
type: "not_found",
details: [
{
field: "contactAttributeKey",
issue: "not found",
},
],
});
}
});
});

View File

@@ -0,0 +1,106 @@
import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { getContactAttributeKeysQuery } from "../utils";
describe("getContactAttributeKeysQuery", () => {
const environmentId = "env-123";
const baseParams: TGetContactAttributeKeysFilter = {
limit: 10,
skip: 0,
order: "asc",
sortBy: "createdAt",
};
beforeEach(() => {
vi.clearAllMocks();
});
test("returns query with environmentId in array when no params are provided", () => {
const environmentIds = ["env-1", "env-2"];
const result = getContactAttributeKeysQuery(environmentIds);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
});
});
test("applies common filters when provided", () => {
const environmentIds = ["env-1", "env-2"];
const params: TGetContactAttributeKeysFilter = {
...baseParams,
environmentId,
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
take: 10,
orderBy: {
createdAt: "asc",
},
});
});
test("applies date filters when provided", () => {
const environmentIds = ["env-1", "env-2"];
const startDate = new Date("2023-01-01");
const endDate = new Date("2023-12-31");
const params: TGetContactAttributeKeysFilter = {
...baseParams,
environmentId,
startDate,
endDate,
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
createdAt: {
gte: startDate,
lte: endDate,
},
},
take: 10,
orderBy: {
createdAt: "asc",
},
});
});
test("handles multiple filter parameters correctly", () => {
const environmentIds = ["env-1", "env-2"];
const params: TGetContactAttributeKeysFilter = {
environmentId,
limit: 5,
skip: 10,
sortBy: "updatedAt",
order: "asc",
};
const result = getContactAttributeKeysQuery(environmentIds, params);
expect(result).toEqual({
where: {
environmentId: {
in: environmentIds,
},
},
take: 5,
skip: 10,
orderBy: {
updatedAt: "asc",
},
});
});
});

View File

@@ -0,0 +1,26 @@
import { TGetContactAttributeKeysFilter } from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { buildCommonFilterQuery, pickCommonFilter } from "@/modules/api/v2/management/lib/utils";
import { Prisma } from "@prisma/client";
export const getContactAttributeKeysQuery = (
environmentIds: string[],
params?: TGetContactAttributeKeysFilter
): Prisma.ContactAttributeKeyFindManyArgs => {
let query: Prisma.ContactAttributeKeyFindManyArgs = {
where: {
environmentId: {
in: environmentIds,
},
},
};
if (!params) return query;
const baseFilter = pickCommonFilter(params);
if (baseFilter) {
query = buildCommonFilterQuery<Prisma.ContactAttributeKeyFindManyArgs>(query, baseFilter);
}
return query;
};

View File

@@ -0,0 +1,73 @@
import { authenticatedApiClient } from "@/modules/api/v2/auth/authenticated-api-client";
import { responses } from "@/modules/api/v2/lib/response";
import { handleApiError } from "@/modules/api/v2/lib/utils";
import {
createContactAttributeKey,
getContactAttributeKeys,
} from "@/modules/api/v2/management/contact-attribute-keys/lib/contact-attribute-key";
import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { NextRequest } from "next/server";
export const GET = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
query: ZGetContactAttributeKeysFilter.sourceType(),
},
handler: async ({ authentication, parsedInput }) => {
const { query } = parsedInput;
let environmentIds: string[] = [];
if (query.environmentId) {
if (!hasPermission(authentication.environmentPermissions, query.environmentId, "GET")) {
return handleApiError(request, {
type: "unauthorized",
});
}
environmentIds = [query.environmentId];
} else {
environmentIds = authentication.environmentPermissions.map((permission) => permission.environmentId);
}
const res = await getContactAttributeKeys(environmentIds, query);
if (!res.ok) {
return handleApiError(request, res.error);
}
return responses.successResponse(res.data);
},
});
export const POST = async (request: NextRequest) =>
authenticatedApiClient({
request,
schemas: {
body: ZContactAttributeKeyInput,
},
handler: async ({ authentication, parsedInput }) => {
const { body } = parsedInput;
if (!hasPermission(authentication.environmentPermissions, body.environmentId, "POST")) {
return handleApiError(request, {
type: "forbidden",
details: [
{ field: "environmentId", issue: "does not have permission to create contact attribute key" },
],
});
}
const createContactAttributeKeyResult = await createContactAttributeKey(body);
if (!createContactAttributeKeyResult.ok) {
return handleApiError(request, createContactAttributeKeyResult.error);
}
return responses.createdResponse(createContactAttributeKeyResult);
},
});

View File

@@ -1,18 +1,13 @@
import { ZGetFilter } from "@/modules/api/v2/types/api-filter";
import { z } from "zod";
import { extendZodWithOpenApi } from "zod-openapi";
import { ZContactAttributeKey } from "@formbricks/database/zod/contact-attribute-keys";
extendZodWithOpenApi(z);
export const ZGetContactAttributeKeysFilter = z
.object({
limit: z.coerce.number().positive().min(1).max(100).optional().default(10),
skip: z.coerce.number().nonnegative().optional().default(0),
sortBy: z.enum(["createdAt", "updatedAt"]).optional().default("createdAt"),
order: z.enum(["asc", "desc"]).optional().default("desc"),
startDate: z.coerce.date().optional(),
endDate: z.coerce.date().optional(),
})
export const ZGetContactAttributeKeysFilter = ZGetFilter.extend({
environmentId: z.string().cuid2().optional().describe("The environment ID to filter by"),
})
.refine(
(data) => {
if (data.startDate && data.endDate && data.startDate > data.endDate) {
@@ -23,13 +18,15 @@ export const ZGetContactAttributeKeysFilter = z
{
message: "startDate must be before endDate",
}
);
)
.describe("Filter for retrieving contact attribute keys");
export type TGetContactAttributeKeysFilter = z.infer<typeof ZGetContactAttributeKeysFilter>;
export const ZContactAttributeKeyInput = ZContactAttributeKey.pick({
key: true,
name: true,
description: true,
type: true,
environmentId: true,
}).openapi({
ref: "contactAttributeKeyInput",

View File

@@ -14,7 +14,8 @@ type HasFindMany =
| Prisma.ResponseFindManyArgs
| Prisma.TeamFindManyArgs
| Prisma.ProjectTeamFindManyArgs
| Prisma.UserFindManyArgs;
| Prisma.UserFindManyArgs
| Prisma.ContactAttributeKeyFindManyArgs;
export function buildCommonFilterQuery<T extends HasFindMany>(query: T, params: TGetFilter): T {
const { limit, skip, sortBy, order, startDate, endDate } = params || {};

View File

@@ -1,5 +1,6 @@
import { cache } from "@/lib/cache";
import { organizationCache } from "@/lib/organization/cache";
import { getBillingPeriodStartDate } from "@/lib/utils/billing";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
@@ -133,22 +134,7 @@ export const getMonthlyOrganizationResponseCount = reactCache(async (organizatio
}
// Determine the start date based on the plan type
let startDate: Date;
if (billing.data.plan === "free") {
// For free plans, use the first day of the current calendar month
const now = new Date();
startDate = new Date(now.getFullYear(), now.getMonth(), 1);
} else {
// For other plans, use the periodStart from billing
if (!billing.data.periodStart) {
return err({
type: "internal_server_error",
details: [{ field: "organization", issue: "billing period start is not set" }],
});
}
startDate = billing.data.periodStart;
}
const startDate = getBillingPeriodStartDate(billing.data);
// Get all environment IDs for the organization
const environmentIdsResult = await getAllEnvironmentsFromOrganizationId(organizationId);

View File

@@ -81,6 +81,6 @@ export const POST = async (request: Request) =>
return handleApiError(request, createResponseResult.error);
}
return responses.successResponse({ data: createResponseResult.data });
return responses.createdResponse({ data: createResponseResult.data });
},
});

View File

@@ -72,6 +72,6 @@ export const POST = async (request: NextRequest) =>
return handleApiError(request, createWebhookResult.error);
}
return responses.successResponse(createWebhookResult);
return responses.createdResponse(createWebhookResult);
},
});

View File

@@ -1,4 +1,4 @@
// import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
import { contactAttributeKeyPaths } from "@/modules/api/v2/management/contact-attribute-keys/lib/openapi";
// import { contactAttributePaths } from "@/modules/api/v2/management/contact-attributes/lib/openapi";
// import { contactPaths } from "@/modules/api/v2/management/contacts/lib/openapi";
import { responsePaths } from "@/modules/api/v2/management/responses/lib/openapi";
@@ -42,7 +42,7 @@ const document = createDocument({
...bulkContactPaths,
// ...contactPaths,
// ...contactAttributePaths,
// ...contactAttributeKeyPaths,
...contactAttributeKeyPaths,
...surveyPaths,
...surveyContactLinksBySegmentPaths,
...webhookPaths,

View File

@@ -59,6 +59,6 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
return handleApiError(request, createTeamResult.error);
}
return responses.successResponse({ data: createTeamResult.data });
return responses.createdResponse({ data: createTeamResult.data });
},
});

View File

@@ -79,7 +79,7 @@ export const POST = async (request: Request, props: { params: Promise<{ organiza
return handleApiError(request, createUserResult.error);
}
return responses.successResponse({ data: createUserResult.data });
return responses.createdResponse({ data: createUserResult.data });
},
});

View File

@@ -0,0 +1,103 @@
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import type { TTemplate, TTemplateFilter } from "@formbricks/types/templates";
import { TemplateTags, getRoleBasedStyling } from "./template-tags";
vi.mock("../lib/utils", () => ({
getRoleMapping: () => [{ value: "marketing", label: "Marketing" }],
getChannelMapping: () => [
{ value: "email", label: "Email Survey" },
{ value: "chat", label: "Chat Survey" },
{ value: "sms", label: "SMS Survey" },
],
getIndustryMapping: () => [
{ value: "indA", label: "Industry A" },
{ value: "indB", label: "Industry B" },
],
}));
const baseTemplate = {
role: "marketing",
channels: ["email"],
industries: ["indA"],
preset: { questions: [] },
} as unknown as TTemplate;
const noFilter: TTemplateFilter[] = [null, null];
describe("TemplateTags", () => {
afterEach(() => {
cleanup();
});
test("getRoleBasedStyling for productManager", () => {
expect(getRoleBasedStyling("productManager")).toBe("border-blue-300 bg-blue-50 text-blue-500");
});
test("getRoleBasedStyling for sales", () => {
expect(getRoleBasedStyling("sales")).toBe("border-emerald-300 bg-emerald-50 text-emerald-500");
});
test("getRoleBasedStyling for customerSuccess", () => {
expect(getRoleBasedStyling("customerSuccess")).toBe("border-violet-300 bg-violet-50 text-violet-500");
});
test("getRoleBasedStyling for peopleManager", () => {
expect(getRoleBasedStyling("peopleManager")).toBe("border-pink-300 bg-pink-50 text-pink-500");
});
test("getRoleBasedStyling default case", () => {
expect(getRoleBasedStyling(undefined)).toBe("border-slate-300 bg-slate-50 text-slate-500");
});
test("renders role tag with correct styling and label", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
const role = screen.getByText("Marketing");
expect(role).toHaveClass("border-orange-300", "bg-orange-50", "text-orange-500");
});
test("single channel shows label without suffix", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
expect(screen.getByText("Email Survey")).toBeInTheDocument();
});
test("two channels concatenated with 'common.or'", () => {
const tpl = { ...baseTemplate, channels: ["email", "chat"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.getByText("Chat common.or Email")).toBeInTheDocument();
});
test("three channels shows 'environments.surveys.templates.all_channels'", () => {
const tpl = { ...baseTemplate, channels: ["email", "chat", "sms"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.getByText("environments.surveys.templates.all_channels")).toBeInTheDocument();
});
test("more than three channels hides channel tag", () => {
const tpl = { ...baseTemplate, channels: ["email", "chat", "sms", "email"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.queryByText(/Survey|common\.or|all_channels/)).toBeNull();
});
test("single industry shows mapped label", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={noFilter} />);
expect(screen.getByText("Industry A")).toBeInTheDocument();
});
test("multiple industries shows 'multiple_industries'", () => {
const tpl = { ...baseTemplate, industries: ["indA", "indB"] } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(screen.getByText("environments.surveys.templates.multiple_industries")).toBeInTheDocument();
});
test("selectedFilter[1] overrides industry tag", () => {
render(<TemplateTags template={baseTemplate} selectedFilter={[null, "marketing"]} />);
expect(screen.getByText("Marketing")).toBeInTheDocument();
});
test("renders branching logic icon when questions have logic", () => {
const tpl = { ...baseTemplate, preset: { questions: [{ logic: [1] }] } } as unknown as TTemplate;
render(<TemplateTags template={tpl} selectedFilter={noFilter} />);
expect(document.querySelector("svg")).toBeInTheDocument();
});
});

View File

@@ -2,8 +2,7 @@
import { cn } from "@/lib/cn";
import { TooltipRenderer } from "@/modules/ui/components/tooltip";
import { useTranslate } from "@tolgee/react";
import { TFnType } from "@tolgee/react";
import { TFnType, useTranslate } from "@tolgee/react";
import { SplitIcon } from "lucide-react";
import { useMemo } from "react";
import { TProjectConfigChannel, TProjectConfigIndustry } from "@formbricks/types/project";
@@ -17,7 +16,7 @@ interface TemplateTagsProps {
type NonNullabeChannel = NonNullable<TProjectConfigChannel>;
const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
export const getRoleBasedStyling = (role: TTemplateRole | undefined): string => {
switch (role) {
case "productManager":
return "border-blue-300 bg-blue-50 text-blue-500";
@@ -44,7 +43,8 @@ const getChannelTag = (channels: NonNullabeChannel[] | undefined, t: TFnType): s
if (label) return t(label);
return undefined;
})
.sort();
.filter((label): label is string => !!label)
.sort((a, b) => a.localeCompare(b));
const removeSurveySuffix = (label: string | undefined) => label?.replace(" Survey", "");

View File

@@ -0,0 +1,152 @@
import { MAX_STRING_LENGTH, extractParts, formatTextWithSlashes } from "@/modules/survey/editor/lib/utils";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
describe("utils.tsx", () => {
describe("extractParts", () => {
test("should handle plain text without delimiters", () => {
const input = "How satisfied are you with the product?";
const expected = ["How satisfied are you with the product?"];
expect(extractParts(input)).toEqual(expected);
});
test("should extract single highlighted part between / and \\", () => {
const input =
"Overall product feedback? /How easy is it to use the interface?\\ /Share your thoughts\\";
const expected = [
"Overall product feedback? ",
"How easy is it to use the interface?",
" ",
"Share your thoughts",
];
expect(extractParts(input)).toEqual(expected);
});
test("should handle multiple highlighted parts", () => {
const input =
"Rate our product /How likely are you to recommend it?\\ / Any issues faced? \\ /What features do you like?\\ Final comments";
const expected = [
"Rate our product ",
"How likely are you to recommend it?",
" ",
" Any issues faced? ",
" ",
"What features do you like?",
" Final comments",
];
expect(extractParts(input)).toEqual(expected);
});
test("should handle unmatched opening delimiter", () => {
const input = "Customer support experience /How responsive was our team?";
const expected = ["Customer support experience /How responsive was our team?"];
expect(extractParts(input)).toEqual(expected);
});
test("should handle unmatched closing delimiter", () => {
const input = "Customer support experience How responsive was our team?\\";
const expected = ["Customer support experience How responsive was our team?\\"];
expect(extractParts(input)).toEqual(expected);
});
test("should handle nested delimiters", () => {
const input = "Customer support experience //How responsive/ was our\\ team?\\";
const expected = ["Customer support experience ", "/How responsive/ was our", " team?\\"];
expect(extractParts(input)).toEqual(expected);
});
test("should handle special characters", () => {
const input = "Customer's support @experience //How responsive/ was our\\ team?\\";
const expected = ["Customer's support @experience ", "/How responsive/ was our", " team?\\"];
expect(extractParts(input)).toEqual(expected);
});
test("should handle empty string", () => {
const input = "";
const expected = [""];
expect(extractParts(input)).toEqual(expected);
});
test("should handle text longer than MAX_STRING_LENGTH", () => {
const longText = "a".repeat(MAX_STRING_LENGTH + 1); // Exceeds MAX_STRING_LENGTH
const expected = [longText];
expect(extractParts(longText)).toEqual(expected);
});
test("should handle text of length MAX_STRING_LENGTH", () => {
const longText = "a".repeat(MAX_STRING_LENGTH); // Equal to MAX_STRING_LENGTH
const expected = [longText];
expect(extractParts(longText)).toEqual(expected);
});
test("should handle only delimiters", () => {
const input = "/\\";
const expected = [""];
expect(extractParts(input)).toEqual(expected);
});
});
describe("formatTextWithSlashes", () => {
afterEach(() => {
cleanup();
});
test("should handle plain text without delimiters", () => {
const input = "How satisfied are you with the product?";
const result = formatTextWithSlashes(input);
expect(result).toEqual(["How satisfied are you with the product?"]);
});
test("should format space separated highlighted parts (i.e. parts between / and \\)", () => {
const input =
"Overall product feedback? /How easy is it to use the interface?\\ /Share your thoughts\\";
const result = formatTextWithSlashes(input);
expect(result).toHaveLength(4);
expect(result[0]).toBe("Overall product feedback? ");
render(<div>{result[1]}</div>);
expect(screen.getByText("How easy is it to use the interface?")).toBeInTheDocument();
expect(screen.getByText("How easy is it to use the interface?")).toHaveAttribute(
"class",
"mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs"
);
expect(result[2]).toBe(" ");
render(<div>{result[3]}</div>);
expect(screen.getByText("Share your thoughts")).toBeInTheDocument();
expect(screen.getByText("Share your thoughts")).toHaveAttribute(
"class",
"mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs"
);
});
test("should format space separated highlighted parts (i.e. parts between / and \\) with prefix and custom class", () => {
const input =
"Overall product feedback? /How easy is it to use the interface?\\ /Share your thoughts\\";
const result = formatTextWithSlashes(input, "@", ["text-sm", "font-bold"]);
expect(result).toHaveLength(4);
expect(result[0]).toBe("Overall product feedback? ");
render(<div>{result[1]}</div>);
expect(screen.getByText("@How easy is it to use the interface?")).toBeInTheDocument();
expect(screen.getByText("@How easy is it to use the interface?")).toHaveAttribute(
"class",
"mx-1 rounded-md bg-slate-100 p-1 px-2 text-sm font-bold"
);
expect(result[2]).toBe(" ");
render(<div>{result[3]}</div>);
expect(screen.getByText("@Share your thoughts")).toBeInTheDocument();
expect(screen.getByText("@Share your thoughts")).toHaveAttribute(
"class",
"mx-1 rounded-md bg-slate-100 p-1 px-2 text-sm font-bold"
);
});
});
});

View File

@@ -5,7 +5,7 @@ import { getQuestionTypes } from "@/modules/survey/lib/questions";
import { TComboboxGroupedOption, TComboboxOption } from "@/modules/ui/components/input-combo-box";
import { TFnType } from "@tolgee/react";
import { EyeOffIcon, FileDigitIcon, FileType2Icon } from "lucide-react";
import { HTMLInputTypeAttribute } from "react";
import { HTMLInputTypeAttribute, JSX } from "react";
import {
TConditionGroup,
TLeftOperand,
@@ -23,16 +23,64 @@ import {
} from "@formbricks/types/surveys/types";
import { TLogicRuleOption, getLogicRules } from "./logic-rule-engine";
export const MAX_STRING_LENGTH = 2000;
export const extractParts = (text: string): string[] => {
const parts: string[] = [];
let i = 0;
if (text.length > MAX_STRING_LENGTH) {
// If the text is unexpectedly too long, return it as a single part
parts.push(text);
return parts;
}
while (i < text.length) {
const start = text.indexOf("/", i);
if (start === -1) {
// No more `/`, push the rest and break
parts.push(text.slice(i));
break;
}
const end = text.indexOf("\\", start + 1);
if (end === -1) {
// No matching `\`, treat as plain text
parts.push(text.slice(i));
break;
}
// Add text before the match
if (start > i) {
parts.push(text.slice(i, start));
}
// Add the highlighted part (without `/` and `\`)
parts.push(text.slice(start + 1, end));
// Move past the `\`
i = end + 1;
}
if (parts.length === 0) {
parts.push(text);
}
return parts;
};
// formats the text to highlight specific parts of the text with slashes
export const formatTextWithSlashes = (text: string) => {
const regex = /\/(.*?)\\/g;
const parts = text.split(regex);
export const formatTextWithSlashes = (
text: string,
prefix: string = "",
classNames: string[] = ["text-xs"]
): (string | JSX.Element)[] => {
const parts = extractParts(text);
return parts.map((part, index) => {
// Check if the part was inside slashes
if (index % 2 !== 0) {
return (
<span key={index} className="mx-1 rounded-md bg-slate-100 p-1 px-2 text-xs">
<span
key={index}
className={`mx-1 rounded-md bg-slate-100 p-1 px-2${classNames ? ` ${classNames.join(" ")}` : ""}`}>
{prefix}
{part}
</span>
);

View File

@@ -17,7 +17,7 @@
.react-calendar__month-view__weekdays {
text-decoration-style: dotted !important;
text-decoration: underline;
text-decoration-line: underline !important;
}
.react-calendar__month-view__days__day--weekend {

View 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");
});
});

View File

@@ -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>
);
};

View File

@@ -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"],

View File

@@ -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",
@@ -59,6 +74,7 @@ export default defineConfig({
"modules/organization/settings/api-keys/components/*.tsx",
"modules/survey/hooks/*.tsx",
"modules/survey/components/question-form-input/index.tsx",
"modules/survey/components/template-list/components/template-tags.tsx",
"modules/survey/lib/client-utils.ts",
"modules/survey/list/components/survey-card.tsx",
"modules/survey/list/components/survey-dropdown-menu.tsx",
@@ -74,6 +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",
],
exclude: [
"**/.next/**",

View File

@@ -1627,6 +1627,386 @@ paths:
- skippedContacts
required:
- data
/contact-attribute-keys:
servers: *a6
get:
operationId: getContactAttributeKeys
summary: Get contact attribute keys
description: Gets contact attribute keys from the database.
tags:
- Management API > Contact Attribute Keys
parameters:
- in: query
name: limit
description: Number of items to return
schema:
type: number
minimum: 1
maximum: 250
default: 50
description: Number of items to return
- in: query
name: skip
description: Number of items to skip
schema:
type: number
minimum: 0
default: 0
description: Number of items to skip
- in: query
name: sortBy
description: Sort by field
schema:
type: string
enum: *a7
default: createdAt
description: Sort by field
- in: query
name: order
description: Sort order
schema:
type: string
enum: *a8
default: desc
description: Sort order
- in: query
name: startDate
description: Start date
schema:
type: string
description: Start date
- in: query
name: endDate
description: End date
schema:
type: string
description: End date
- in: query
name: environmentId
description: The environment ID to filter by
schema:
type: string
description: The environment ID to filter by
responses:
"200":
description: Contact attribute keys retrieved successfully.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
description: The ID of the contact attribute key
createdAt:
type: string
description: The date and time the contact attribute key was created
example: 2021-01-01T00:00:00.000Z
updatedAt:
type: string
description: The date and time the contact attribute key was last updated
example: 2021-01-01T00:00:00.000Z
isUnique:
type: boolean
description: Whether the attribute must have unique values across contacts
example: false
key:
type: string
description: The attribute identifier used in the system
example: email
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
type:
type: string
enum:
- default
- custom
description: Whether this is a default or custom attribute
example: custom
environmentId:
type: string
description: The ID of the environment this attribute belongs to
meta:
type: object
properties:
total:
type: number
limit:
type: number
offset:
type: number
post:
operationId: createContactAttributeKey
summary: Create a contact attribute key
description: Creates a contact attribute key in the database.
tags:
- Management API > Contact Attribute Keys
requestBody:
required: true
description: The contact attribute key to create
content:
application/json:
schema:
$ref: "#/components/schemas/contactAttributeKeyInput"
responses:
"201":
description: Contact attribute key created successfully.
content:
application/json:
schema:
type: object
properties:
id:
type: string
description: The ID of the contact attribute key
createdAt:
type: string
description: The date and time the contact attribute key was created
example: 2021-01-01T00:00:00.000Z
updatedAt:
type: string
description: The date and time the contact attribute key was last updated
example: 2021-01-01T00:00:00.000Z
isUnique:
type: boolean
description: Whether the attribute must have unique values across contacts
example: false
key:
type: string
description: The attribute identifier used in the system
example: email
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
type:
type: string
enum:
- default
- custom
description: Whether this is a default or custom attribute
example: custom
environmentId:
type: string
description: The ID of the environment this attribute belongs to
/contact-attribute-keys/{id}:
servers: *a6
get:
operationId: getContactAttributeKey
summary: Get a contact attribute key
description: Gets a contact attribute key from the database.
tags:
- Management API > Contact Attribute Keys
parameters:
- in: path
name: id
description: The ID of the contact attribute key
schema:
$ref: "#/components/schemas/contactAttributeKeyId"
required: true
responses:
"200":
description: Contact attribute key retrieved successfully.
content:
application/json:
schema:
type: object
properties:
id:
type: string
description: The ID of the contact attribute key
createdAt:
type: string
description: The date and time the contact attribute key was created
example: 2021-01-01T00:00:00.000Z
updatedAt:
type: string
description: The date and time the contact attribute key was last updated
example: 2021-01-01T00:00:00.000Z
isUnique:
type: boolean
description: Whether the attribute must have unique values across contacts
example: false
key:
type: string
description: The attribute identifier used in the system
example: email
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
type:
type: string
enum:
- default
- custom
description: Whether this is a default or custom attribute
example: custom
environmentId:
type: string
description: The ID of the environment this attribute belongs to
put:
operationId: updateContactAttributeKey
summary: Update a contact attribute key
description: Updates a contact attribute key in the database.
tags:
- Management API > Contact Attribute Keys
parameters:
- in: path
name: id
description: The ID of the contact attribute key
schema:
$ref: "#/components/schemas/contactAttributeKeyId"
required: true
requestBody:
required: true
description: The contact attribute key to update
content:
application/json:
schema:
$ref: "#/components/schemas/contactAttributeKeyUpdate"
responses:
"200":
description: Contact attribute key updated successfully.
content:
application/json:
schema:
type: object
properties:
id:
type: string
description: The ID of the contact attribute key
createdAt:
type: string
description: The date and time the contact attribute key was created
example: 2021-01-01T00:00:00.000Z
updatedAt:
type: string
description: The date and time the contact attribute key was last updated
example: 2021-01-01T00:00:00.000Z
isUnique:
type: boolean
description: Whether the attribute must have unique values across contacts
example: false
key:
type: string
description: The attribute identifier used in the system
example: email
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
type:
type: string
enum:
- default
- custom
description: Whether this is a default or custom attribute
example: custom
environmentId:
type: string
description: The ID of the environment this attribute belongs to
delete:
operationId: deleteContactAttributeKey
summary: Delete a contact attribute key
description: Deletes a contact attribute key from the database.
tags:
- Management API > Contact Attribute Keys
parameters:
- in: path
name: id
description: The ID of the contact attribute key
schema:
$ref: "#/components/schemas/contactAttributeKeyId"
required: true
responses:
"200":
description: Contact attribute key deleted successfully.
content:
application/json:
schema:
type: object
properties:
id:
type: string
description: The ID of the contact attribute key
createdAt:
type: string
description: The date and time the contact attribute key was created
example: 2021-01-01T00:00:00.000Z
updatedAt:
type: string
description: The date and time the contact attribute key was last updated
example: 2021-01-01T00:00:00.000Z
isUnique:
type: boolean
description: Whether the attribute must have unique values across contacts
example: false
key:
type: string
description: The attribute identifier used in the system
example: email
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
type:
type: string
enum:
- default
- custom
description: Whether this is a default or custom attribute
example: custom
environmentId:
type: string
description: The ID of the environment this attribute belongs to
/surveys/{surveyId}/contact-links/contacts/{contactId}/:
servers: *a6
get:
@@ -4218,6 +4598,61 @@ components:
responseId:
type: string
description: The ID of the response
contactAttributeKeyInput:
type: object
properties:
key:
type: string
description: The attribute identifier used in the system
example: email
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
environmentId:
type: string
description: The ID of the environment this attribute belongs to
required:
- key
- name
- description
- environmentId
description: Input data for creating or updating a contact attribute
contactAttributeKeyId:
type: string
description: The ID of the contact attribute key
contactAttributeKeyUpdate:
type: object
properties:
name:
type:
- string
- "null"
description: Display name for the attribute
example: Email Address
description:
type:
- string
- "null"
description: Description of the attribute
example: The user's email address
key:
type: string
description: The attribute identifier used in the system
example: email
required:
- name
- description
- key
description: A contact attribute key to update.
webhookId:
type: string
description: The ID of the webhook

View File

@@ -96,7 +96,7 @@ locals {
metric_name = "CPUUtilization"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora.cluster_instances["one"].id
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_FreeStorageSpace = {
@@ -110,7 +110,7 @@ locals {
metric_name = "FreeStorageSpace"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora.cluster_instances["one"].id
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_FreeableMemory = {
@@ -124,7 +124,7 @@ locals {
metric_name = "FreeableMemory"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora.cluster_instances["one"].id
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_DiskQueueDepth = {
@@ -138,7 +138,7 @@ locals {
metric_name = "DiskQueueDepth"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora.cluster_instances["one"].id
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_ReadIOPS = {
@@ -152,7 +152,7 @@ locals {
metric_name = "ReadIOPS"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora.cluster_instances["one"].id
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
RDS_WriteIOPS = {
@@ -166,7 +166,7 @@ locals {
metric_name = "WriteIOPS"
statistic = "Average"
dimensions = {
DBInstanceIdentifier = module.rds-aurora.cluster_instances["one"].id
DBInstanceIdentifier = module.rds-aurora["prod"].cluster_instances["one"].id
}
}
SQS_ApproximateAgeOfOldestMessage = {

View File

@@ -5,13 +5,15 @@ locals {
valkey_major_version = 8
}
resource "random_password" "valkey" {
length = 20
special = false
moved {
from = random_password.valkey
to = random_password.valkey["prod"]
}
resource "random_password" "valkey_default_user" {
length = 20
special = false
resource "random_password" "valkey" {
for_each = local.envs
length = 20
special = false
}
module "valkey_sg" {
@@ -28,40 +30,24 @@ module "valkey_sg" {
tags = local.tags
}
module "elasticache_user_group" {
source = "terraform-aws-modules/elasticache/aws//modules/user-group"
version = "1.4.1"
user_group_id = "${local.name}-valkey"
create_default_user = false
default_user = {
user_id = "formbricks-default"
passwords = [random_password.valkey_default_user.result]
}
users = {
formbricks = {
access_string = "on ~* +@all"
passwords = [random_password.valkey.result]
}
}
engine = "redis"
tags = merge(local.tags, {
terraform-aws-modules = "elasticache"
})
moved {
from = module.valkey
to = module.valkey["prod"]
}
module "valkey" {
source = "terraform-aws-modules/elasticache/aws"
version = "1.4.1"
for_each = local.envs
source = "terraform-aws-modules/elasticache/aws"
version = "1.4.1"
replication_group_id = "${local.name}-valkey"
replication_group_id = "${each.value}-valkey"
engine = "valkey"
engine_version = "8.0"
node_type = "cache.m7g.large"
transit_encryption_enabled = true
auth_token = random_password.valkey.result
auth_token = random_password.valkey[each.key].result
maintenance_window = "sun:05:00-sun:09:00"
apply_immediately = true
@@ -85,15 +71,15 @@ module "valkey" {
}
# Subnet Group
subnet_group_name = "${local.name}-valkey"
subnet_group_description = "${title(local.name)} subnet group"
subnet_group_name = "${each.value}-valkey"
subnet_group_description = "${title(each.value)} subnet group"
subnet_ids = module.vpc.database_subnets
# Parameter Group
create_parameter_group = true
parameter_group_name = "${local.name}-valkey-${local.valkey_major_version}"
parameter_group_name = "${each.value}-valkey-${local.valkey_major_version}"
parameter_group_family = "valkey8"
parameter_group_description = "${title(local.name)} parameter group"
parameter_group_description = "${title(each.value)} parameter group"
parameters = [
{
name = "latency-tracking"
@@ -101,20 +87,5 @@ module "valkey" {
}
]
tags = local.tags
}
module "valkey_serverless" {
source = "terraform-aws-modules/elasticache/aws//modules/serverless-cache"
version = "1.4.1"
engine = "valkey"
cache_name = "${local.name}-valkey-serverless"
major_engine_version = 8
subnet_ids = module.vpc.database_subnets
security_group_ids = [
module.valkey_sg.security_group_id
]
user_group_id = module.elasticache_user_group.group_id
tags = local.tags_map[each.key]
}

View File

@@ -2,14 +2,32 @@ locals {
project = "formbricks"
environment = "prod"
name = "${local.project}-${local.environment}"
vpc_cidr = "10.0.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
envs = {
prod = "${local.project}-prod"
stage = "${local.project}-stage"
}
vpc_cidr = "10.0.0.0/16"
azs = slice(data.aws_availability_zones.available.names, 0, 3)
tags = {
Project = local.project
Environment = local.environment
MangedBy = "Terraform"
Blueprint = local.name
}
tags_map = {
prod = {
Project = local.project
Environment = "prod"
MangedBy = "Terraform"
Blueprint = "${local.project}-prod"
}
stage = {
Project = local.project
Environment = "stage"
MangedBy = "Terraform"
Blueprint = "${local.project}-stage"
}
}
domain = "k8s.formbricks.com"
karpetner_helm_version = "1.3.1"
karpenter_namespace = "karpenter"
@@ -419,62 +437,24 @@ module "eks_blueprints_addons" {
}
### Formbricks App
data "aws_iam_policy_document" "replication_bucket_policy" {
statement {
sid = "Set-permissions-for-objects"
effect = "Allow"
principals {
type = "AWS"
identifiers = [
"arn:aws:iam::050559574035:role/service-role/s3crr_role_for_formbricks-cloud-uploads"
]
}
actions = [
"s3:ReplicateObject",
"s3:ReplicateDelete"
]
resources = [
"arn:aws:s3:::formbricks-cloud-eks/*"
]
}
statement {
sid = "Set permissions on bucket"
effect = "Allow"
principals {
type = "AWS"
identifiers = [
"arn:aws:iam::050559574035:role/service-role/s3crr_role_for_formbricks-cloud-uploads"
]
}
actions = [
"s3:GetBucketVersioning",
"s3:PutBucketVersioning"
]
resources = [
"arn:aws:s3:::formbricks-cloud-eks"
]
}
moved {
from = module.formbricks_s3_bucket
to = module.formbricks_s3_bucket["prod"]
}
module "formbricks_s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.6.0"
for_each = local.envs
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.6.0"
bucket = "formbricks-cloud-eks"
bucket = each.key == "prod" ? "formbricks-cloud-eks" : "formbricks-cloud-eks-${each.key}"
force_destroy = true
control_object_ownership = true
object_ownership = "BucketOwnerPreferred"
versioning = {
enabled = true
}
policy = data.aws_iam_policy_document.replication_bucket_policy.json
cors_rule = [
{
allowed_methods = ["POST"]
@@ -485,11 +465,17 @@ module "formbricks_s3_bucket" {
]
}
module "formbricks_app_iam_policy" {
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
version = "5.53.0"
moved {
from = module.formbricks_app_iam_policy
to = module.formbricks_app_iam_policy["prod"]
}
name_prefix = "formbricks-"
module "formbricks_app_iam_policy" {
for_each = local.envs
source = "terraform-aws-modules/iam/aws//modules/iam-policy"
version = "5.53.0"
name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
path = "/"
description = "Policy for fombricks app"
@@ -502,31 +488,35 @@ module "formbricks_app_iam_policy" {
"s3:*",
]
Resource = [
module.formbricks_s3_bucket.s3_bucket_arn,
"${module.formbricks_s3_bucket.s3_bucket_arn}/*",
"arn:aws:s3:::formbricks-cloud-uploads",
"arn:aws:s3:::formbricks-cloud-uploads/*"
module.formbricks_s3_bucket[each.key].s3_bucket_arn,
"${module.formbricks_s3_bucket[each.key].s3_bucket_arn}/*"
]
}
]
})
}
module "formbricks_app_iam_role" {
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
moved {
from = module.formbricks_app_iam_role
to = module.formbricks_app_iam_role["prod"]
}
role_name_prefix = "formbricks-"
module "formbricks_app_iam_role" {
for_each = local.envs
source = "terraform-aws-modules/iam/aws//modules/iam-role-for-service-accounts-eks"
version = "5.53.0"
role_name_prefix = each.key == "prod" ? "formbricks-" : "formbricks-${each.key}-"
role_policy_arns = {
"formbricks" = module.formbricks_app_iam_policy.arn
"formbricks" = module.formbricks_app_iam_policy[each.key].arn
}
assume_role_condition_test = "StringLike"
oidc_providers = {
eks = {
provider_arn = module.eks.oidc_provider_arn
namespace_service_accounts = ["formbricks:*"]
namespace_service_accounts = each.key == "prod" ? ["formbricks:*"] : ["formbricks-${each.key}:*"]
}
}
}

View File

@@ -6,22 +6,34 @@ data "aws_rds_engine_version" "postgresql" {
version = "16.4"
}
moved {
from = random_password.postgres
to = random_password.postgres["prod"]
}
resource "random_password" "postgres" {
length = 20
special = false
for_each = local.envs
length = 20
special = false
}
moved {
from = module.rds-aurora
to = module.rds-aurora["prod"]
}
module "rds-aurora" {
source = "terraform-aws-modules/rds-aurora/aws"
version = "9.12.0"
for_each = local.envs
source = "terraform-aws-modules/rds-aurora/aws"
version = "9.12.0"
name = "${local.name}-postgres"
name = "${each.value}-postgres"
engine = data.aws_rds_engine_version.postgresql.engine
engine_mode = "provisioned"
engine_version = data.aws_rds_engine_version.postgresql.version
storage_encrypted = true
master_username = "formbricks"
master_password = random_password.postgres.result
master_password = random_password.postgres[each.key].result
manage_master_user_password = false
create_db_cluster_parameter_group = true
db_cluster_parameter_group_family = data.aws_rds_engine_version.postgresql.parameter_group_family
@@ -63,6 +75,6 @@ module "rds-aurora" {
one = {}
}
tags = local.tags
tags = local.tags_map[each.key]
}

View File

@@ -1,25 +1,24 @@
# Create the first AWS Secrets Manager secret for environment variables
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
name = "prod/formbricks/secrets"
moved {
from = aws_secretsmanager_secret.formbricks_app_secrets
to = aws_secretsmanager_secret.formbricks_app_secrets["prod"]
}
resource "aws_secretsmanager_secret" "formbricks_app_secrets_temp" {
name = "prod/formbricks/secrets_temp"
resource "aws_secretsmanager_secret" "formbricks_app_secrets" {
for_each = local.envs
name = "${each.key}/formbricks/secrets"
}
moved {
from = aws_secretsmanager_secret_version.formbricks_app_secrets
to = aws_secretsmanager_secret_version.formbricks_app_secrets["prod"]
}
resource "aws_secretsmanager_secret_version" "formbricks_app_secrets" {
secret_id = aws_secretsmanager_secret.formbricks_app_secrets.id
for_each = local.envs
secret_id = aws_secretsmanager_secret.formbricks_app_secrets[each.key].id
secret_string = jsonencode({
# DATABASE_URL = "postgres://formbricks:${random_password.postgres.result}@${module.rds-aurora.cluster_endpoint}/formbricks"
REDIS_URL = "rediss://:${random_password.valkey.result}@${module.valkey.replication_group_primary_endpoint_address}:6379"
# REDIS_URL = "rediss://formbricks:${random_password.valkey.result}@${module.valkey_serverless.serverless_cache_endpoint[0].address}:6379"
REDIS_URL = "rediss://:${random_password.valkey[each.key].result}@${module.valkey[each.key].replication_group_primary_endpoint_address}:6379"
})
}
resource "aws_secretsmanager_secret_version" "formbricks_app_secrets_temp" {
secret_id = aws_secretsmanager_secret.formbricks_app_secrets_temp.id
secret_string = jsonencode({
DATABASE_URL = "postgres://formbricks:${random_password.postgres.result}@${module.rds-aurora.cluster_endpoint}/formbricks"
# REDIS_URL = "rediss://formbricks:${random_password.valkey.result}@${module.valkey_serverless.serverless_cache_endpoint[0].address}:6379"
})
}

View File

@@ -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"

View File

@@ -10,6 +10,7 @@ import androidx.core.view.WindowInsetsCompat
import com.formbricks.formbrickssdk.Formbricks
import com.formbricks.formbrickssdk.FormbricksCallback
import com.formbricks.formbrickssdk.helper.FormbricksConfig
import com.formbricks.formbrickssdk.model.enums.SuccessType
import java.util.UUID
class MainActivity : AppCompatActivity() {
@@ -30,8 +31,16 @@ class MainActivity : AppCompatActivity() {
Log.d("FormbricksCallback", "onSurveyClosed")
}
override fun onPageCommitVisible() {
Log.d("FormbricksCallback", "onPageCommitVisible")
}
override fun onError(error: Exception) {
Log.d("FormbricksCallback", "onError: ${error.localizedMessage}")
Log.d("FormbricksCallback", "onError from the CB: ${error.localizedMessage}")
}
override fun onSuccess(successType: SuccessType) {
Log.d("FormbricksCallback", "onSuccess: ${successType.name}")
}
}
@@ -39,10 +48,8 @@ class MainActivity : AppCompatActivity() {
val config = FormbricksConfig.Builder("[appUrl]","[environmentId]")
.setLoggingEnabled(true)
.setFragmentManager(supportFragmentManager)
Formbricks.setup(this, config.build())
Formbricks.logout()
Formbricks.setUserId(UUID.randomUUID().toString())
Formbricks.setup(this, config.build())
setContentView(R.layout.activity_main)
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
@@ -55,5 +62,30 @@ class MainActivity : AppCompatActivity() {
button.setOnClickListener {
Formbricks.track("click_demo_button")
}
val setUserIdButton = findViewById<Button>(R.id.setUserId)
setUserIdButton.setOnClickListener {
Formbricks.setUserId(UUID.randomUUID().toString())
}
val setAttributeButton = findViewById<Button>(R.id.setAttribute)
setAttributeButton.setOnClickListener {
Formbricks.setAttribute("test@web.com", "email")
}
val setAttributesButton = findViewById<Button>(R.id.setAttributes)
setAttributesButton.setOnClickListener {
Formbricks.setAttributes(mapOf(Pair("attr1", "val1"), Pair("attr2", "val2")))
}
val setLanguageButton = findViewById<Button>(R.id.setLanguage)
setLanguageButton.setOnClickListener {
Formbricks.setLanguage("vi")
}
val logoutButton = findViewById<Button>(R.id.logout)
logoutButton.setOnClickListener {
Formbricks.logout()
}
}
}

View File

@@ -11,11 +11,83 @@
android:id="@+id/button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Click me!"
android:text="Track Action"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintHorizontal_bias="0.495"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="158dp"
tools:layout_editor_absoluteY="336dp" />
app:layout_constraintVertical_bias="0.24" />
<Button
android:id="@+id/setUserId"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="161dp"
android:layout_marginTop="27dp"
android:layout_marginEnd="154dp"
android:layout_marginBottom="12dp"
android:text="setUserId"
android:visibility="visible"
app:layout_constraintBottom_toTopOf="@+id/setLanguage"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/button"
tools:visibility="visible" />
<Button
android:id="@+id/setLanguage"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="161dp"
android:layout_marginTop="12dp"
android:layout_marginEnd="128dp"
android:layout_marginBottom="11dp"
android:text="setLanguage"
app:layout_constraintBottom_toTopOf="@+id/setAttribute"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/setUserId" />
<Button
android:id="@+id/setAttribute"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="161dp"
android:layout_marginTop="5dp"
android:layout_marginEnd="128dp"
android:layout_marginBottom="2dp"
android:text="setAttribute"
app:layout_constraintBottom_toTopOf="@+id/setAttributes"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/setLanguage" />
<Button
android:id="@+id/setAttributes"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="161dp"
android:layout_marginTop="1dp"
android:layout_marginEnd="120dp"
android:layout_marginBottom="10dp"
android:text="setAttributes"
app:layout_constraintBottom_toTopOf="@+id/logout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/setAttribute" />
<Button
android:id="@+id/logout"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="177dp"
android:layout_marginTop="9dp"
android:layout_marginEnd="146dp"
android:layout_marginBottom="199dp"
android:text="logout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/setAttributes" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -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>

View File

@@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.0.12</domain>
<domain-config cleartextTrafficPermitted="false">
<domain includeSubdomains="true">192.168.29.120</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

View File

@@ -23,7 +23,7 @@ android {
enableAndroidTestCoverage = true
}
release {
isMinifyEnabled = false
isMinifyEnabled = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"

View File

@@ -1,3 +1,5 @@
-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; }
-keep class com.formbricks.formbrickssdk.Formbricks { *; }
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
-keep interface com.formbricks.formbrickssdk.FormbricksCallback { *; }

View File

@@ -33,4 +33,6 @@
-keep class com.formbricks.formbrickssdk.DataBinderMapperImpl { *; }
-keep class com.formbricks.formbrickssdk.Formbricks { *; }
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
-keep class com.formbricks.formbrickssdk.helper.FormbricksConfig { *; }
-keep class com.formbricks.formbrickssdk.model.error.SDKError { *; }
-keep interface com.formbricks.formbrickssdk.FormbricksCallback { *; }

View File

@@ -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>

View File

@@ -10,8 +10,21 @@ import com.formbricks.formbrickssdk.helper.FormbricksConfig
import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.manager.SurveyManager
import com.formbricks.formbrickssdk.manager.UserManager
import com.formbricks.formbrickssdk.model.enums.SuccessType
import com.formbricks.formbrickssdk.model.error.SDKError
import com.formbricks.formbrickssdk.webview.FormbricksFragment
import java.lang.RuntimeException
@Keep
interface FormbricksCallback {
fun onSurveyStarted()
fun onSurveyFinished()
fun onSurveyClosed()
fun onPageCommitVisible()
fun onError(error: Exception)
fun onSuccess(successType: SuccessType)
}
@Keep
object Formbricks {
@@ -24,6 +37,8 @@ object Formbricks {
private var fragmentManager: FragmentManager? = null
internal var isInitialized = false
var callback: FormbricksCallback? = null
/**
* Initializes the Formbricks SDK with the given [Context] config [FormbricksConfig].
* This method is mandatory to be called, and should be only once per application lifecycle.
@@ -44,7 +59,14 @@ object Formbricks {
* ```
*
*/
fun setup(context: Context, config: FormbricksConfig) {
fun setup(context: Context, config: FormbricksConfig, forceRefresh: Boolean = false) {
if (isInitialized && !forceRefresh) {
val error = SDKError.sdkIsAlreadyInitialized
callback?.onError(error)
Logger.e(error)
return
}
applicationContext = context
appUrl = config.appUrl
@@ -57,7 +79,7 @@ object Formbricks {
config.attributes?.get("language")?.let { UserManager.setLanguage(it) }
FormbricksApi.initialize()
SurveyManager.refreshEnvironmentIfNeeded()
SurveyManager.refreshEnvironmentIfNeeded(force = forceRefresh)
UserManager.syncUserStateIfNeeded()
isInitialized = true
@@ -74,9 +96,19 @@ object Formbricks {
*/
fun setUserId(userId: String) {
if (!isInitialized) {
Logger.e(exception = SDKError.sdkIsNotInitialized)
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error)
return
}
if(UserManager.userId != null) {
val error = RuntimeException("A userId is already set ${UserManager.userId} - please call logout first before setting a new one")
callback?.onError(error)
Logger.e(error)
return
}
UserManager.set(userId)
}
@@ -91,7 +123,9 @@ object Formbricks {
*/
fun setAttribute(attribute: String, key: String) {
if (!isInitialized) {
Logger.e(exception = SDKError.sdkIsNotInitialized)
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error)
return
}
UserManager.addAttribute(attribute, key)
@@ -108,7 +142,9 @@ object Formbricks {
*/
fun setAttributes(attributes: Map<String, String>) {
if (!isInitialized) {
Logger.e(exception = SDKError.sdkIsNotInitialized)
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error)
return
}
UserManager.setAttributes(attributes)
@@ -125,7 +161,9 @@ object Formbricks {
*/
fun setLanguage(language: String) {
if (!isInitialized) {
Logger.e(exception = SDKError.sdkIsNotInitialized)
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error)
return
}
Formbricks.language = language
@@ -143,12 +181,16 @@ object Formbricks {
*/
fun track(action: String) {
if (!isInitialized) {
Logger.e(exception = SDKError.sdkIsNotInitialized)
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error)
return
}
if (!isInternetAvailable()) {
Logger.w(exception = SDKError.connectionIsNotAvailable)
val error = SDKError.connectionIsNotAvailable
callback?.onError(error)
Logger.e(error)
return
}
@@ -166,10 +208,13 @@ object Formbricks {
*/
fun logout() {
if (!isInitialized) {
Logger.e(exception = SDKError.sdkIsNotInitialized)
val error = SDKError.sdkIsNotInitialized
callback?.onError(error)
Logger.e(error)
return
}
callback?.onSuccess(SuccessType.LOGOUT_SUCCESS)
UserManager.logout()
}
@@ -190,7 +235,9 @@ object Formbricks {
/// Assembles the survey fragment and presents it
internal fun showSurvey(id: String) {
if (fragmentManager == null) {
Logger.e(exception = SDKError.fragmentManagerIsNotSet)
val error = SDKError.fragmentManagerIsNotSet
callback?.onError(error)
Logger.e(error)
return
}

View File

@@ -6,11 +6,26 @@ import com.formbricks.formbrickssdk.model.user.PostUserBody
import com.formbricks.formbrickssdk.model.user.UserResponse
import com.formbricks.formbrickssdk.network.FormbricksApiService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
object FormbricksApi {
var service = FormbricksApiService()
private suspend fun <T> retryApiCall(
retries: Int = 2,
delayTime: Long = 1000,
block: suspend () -> Result<T>
): Result<T> {
repeat(retries) { attempt ->
val result = block()
if (result.isSuccess) return result
println("⚠️ Retry ${attempt + 1} due to error: ${result.exceptionOrNull()?.localizedMessage}")
delay(delayTime)
}
return block()
}
fun initialize() {
service.initialize(
appUrl = Formbricks.appUrl,
@@ -19,21 +34,25 @@ object FormbricksApi {
}
suspend fun getEnvironmentState(): Result<EnvironmentDataHolder> = withContext(Dispatchers.IO) {
try {
val response = service.getEnvironmentStateObject(Formbricks.environmentId)
val result = response.getOrThrow()
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
retryApiCall {
try {
val response = service.getEnvironmentStateObject(Formbricks.environmentId)
val result = response.getOrThrow()
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
}
suspend fun postUser(userId: String, attributes: Map<String, *>?): Result<UserResponse> = withContext(Dispatchers.IO) {
try {
val result = service.postUser(Formbricks.environmentId, PostUserBody.create(userId, attributes)).getOrThrow()
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
retryApiCall {
try {
val result = service.postUser(Formbricks.environmentId, PostUserBody.create(userId, attributes)).getOrThrow()
Result.success(result)
} catch (e: Exception) {
Result.failure(e)
}
}
}
}

View File

@@ -10,9 +10,9 @@ object Logger {
}
}
fun e(message: String? = "Exception", exception: RuntimeException? = null) {
fun e(exception: RuntimeException) {
if (Formbricks.loggingEnabled) {
Log.e("FormbricksSDK", message, exception)
Log.e("FormbricksSDK", exception.localizedMessage, exception)
}
}

View File

@@ -8,11 +8,14 @@ import com.formbricks.formbrickssdk.extensions.guard
import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.model.environment.EnvironmentDataHolder
import com.formbricks.formbrickssdk.model.environment.Survey
import com.formbricks.formbrickssdk.model.error.SDKError
import com.formbricks.formbrickssdk.model.enums.SuccessType
import com.formbricks.formbrickssdk.model.user.Display
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.lang.RuntimeException
import java.util.Date
import java.util.Timer
import java.util.TimerTask
@@ -57,7 +60,8 @@ object SurveyManager {
try {
Gson().fromJson(json, EnvironmentDataHolder::class.java)
} catch (e: Exception) {
Logger.e("Unable to retrieve environment data from the local storage.")
Formbricks.callback?.onError(e)
Logger.e(RuntimeException("Unable to retrieve environment data from the local storage."))
null
}
}
@@ -114,9 +118,12 @@ object SurveyManager {
startRefreshTimer(environmentDataHolder?.expiresAt())
filterSurveys()
hasApiError = false
Formbricks.callback?.onSuccess(SuccessType.GET_ENVIRONMENT_SUCCESS)
} catch (e: Exception) {
hasApiError = true
Logger.e("Unable to refresh environment state.")
val error = SDKError.unableToRefreshEnvironment
Formbricks.callback?.onError(error)
Logger.e(error)
startErrorTimer()
}
}
@@ -135,10 +142,30 @@ object SurveyManager {
triggers.firstOrNull { it.actionClass?.name.equals(actionClass?.name) } != null
}
val shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass?.displayPercentage)
if (firstSurveyWithActionClass == null) {
Formbricks.callback?.onError(SDKError.surveyNotFoundError)
return
}
val isMultiLangSurvey = (firstSurveyWithActionClass.languages?.size ?: 0) > 1
if(isMultiLangSurvey) {
val currentLanguage = Formbricks.language
val languageCode = getLanguageCode(firstSurveyWithActionClass, currentLanguage)
if (languageCode == null) {
val error = RuntimeException("Survey “${firstSurveyWithActionClass.name}” is not available in language “$currentLanguage”. Skipping.")
Formbricks.callback?.onError(error)
Logger.e(error)
return
}
Formbricks.setLanguage(languageCode)
}
val shouldDisplay = shouldDisplayBasedOnPercentage(firstSurveyWithActionClass.displayPercentage)
if (shouldDisplay) {
firstSurveyWithActionClass?.id?.let {
firstSurveyWithActionClass.id.let {
isShowingSurvey = true
val timeout = firstSurveyWithActionClass.delay ?: 0.0
stopDisplayTimer()
@@ -149,16 +176,14 @@ object SurveyManager {
}, Date(System.currentTimeMillis() + timeout.toLong() * 1000))
}
} else {
Formbricks.callback?.onError(SDKError.surveyNotDisplayedError)
}
}
private fun stopDisplayTimer() {
try {
displayTimer.cancel()
displayTimer = Timer()
} catch (_: Exception) {
}
displayTimer.cancel()
displayTimer = Timer()
}
/**
@@ -166,7 +191,9 @@ object SurveyManager {
*/
fun postResponse(surveyId: String?) {
val id = surveyId.guard {
Logger.e("Survey id is mandatory to set.")
val error = SDKError.missingSurveyId
Formbricks.callback?.onError(error)
Logger.e(error)
return
}
@@ -178,7 +205,9 @@ object SurveyManager {
*/
fun onNewDisplay(surveyId: String?) {
val id = surveyId.guard {
Logger.e("Survey id is mandatory to set.")
val error = SDKError.missingSurveyId
Formbricks.callback?.onError(error)
Logger.e(error)
return
}
@@ -239,7 +268,9 @@ object SurveyManager {
}
else -> {
Logger.e("Invalid Display Option")
val error = SDKError.invalidDisplayOption
Formbricks.callback?.onError(error)
Logger.e(error)
false
}
}
@@ -282,4 +313,39 @@ object SurveyManager {
val randomNum = (0 until 10000).random() / 100.0
return randomNum <= percentage
}
private fun getLanguageCode(survey: Survey, language: String?): String? {
// 1) Gather all valid codes
val availableLanguageCodes = survey.languages
?.map { it.language.code }
?: emptyList()
// 2) No input or explicit "default" → default
val raw = language
?.lowercase()
?.takeIf { it.isNotEmpty() }
?: return "default"
if (raw == "default") return "default"
// 3) Find matching entry by code or alias
val selected = survey.languages
?.firstOrNull { entry ->
entry.language.code.lowercase() == raw ||
entry.language.alias?.lowercase() == raw
}
// 4) If that entry is marked default → default
if (selected?.default == true) return "default"
// 5) If missing, disabled, or not in the available list → null
if (selected == null
|| !selected.enabled
|| !availableLanguageCodes.contains(selected.language.code)
) {
return null
}
// 6) Otherwise return its code
return selected.language.code
}
}

View File

@@ -8,6 +8,8 @@ import com.formbricks.formbrickssdk.extensions.expiresAt
import com.formbricks.formbrickssdk.extensions.guard
import com.formbricks.formbrickssdk.extensions.lastDisplayAt
import com.formbricks.formbrickssdk.logger.Logger
import com.formbricks.formbrickssdk.model.error.SDKError
import com.formbricks.formbrickssdk.model.enums.SuccessType
import com.formbricks.formbrickssdk.model.user.Display
import com.formbricks.formbrickssdk.network.queue.UpdateQueue
import com.google.gson.Gson
@@ -136,11 +138,20 @@ object UserManager {
responses = userResponse.data.state.data.responses
lastDisplayedAt = userResponse.data.state.data.lastDisplayAt()
expiresAt = userResponse.data.state.expiresAt()
val languageFromUserResponse = userResponse.data.state.data.language
if(languageFromUserResponse != null) {
Formbricks.language = languageFromUserResponse
}
UpdateQueue.current.reset()
SurveyManager.filterSurveys()
startSyncTimer()
Formbricks.callback?.onSuccess(SuccessType.SET_USER_SUCCESS)
} catch (e: Exception) {
Logger.e("Unable to post survey response.")
val error = SDKError.unableToPostResponse
Formbricks.callback?.onError(error)
Logger.e(error)
}
}
}
@@ -149,7 +160,16 @@ object UserManager {
* Logs out the user and clears the user state.
*/
fun logout() {
val isUserIdDefined = userId != null
if (!isUserIdDefined) {
val error = SDKError.noUserIdSetError
Formbricks.callback?.onError(error)
Logger.e(error)
}
prefManager.edit().apply {
remove(CONTACT_ID_KEY)
remove(USER_ID_KEY)
remove(SEGMENTS_KEY)
remove(DISPLAYS_KEY)
@@ -158,13 +178,20 @@ object UserManager {
remove(EXPIRES_AT_KEY)
apply()
}
backingUserId = null
backingContactId = null
backingSegments = null
backingDisplays = null
backingResponses = null
backingLastDisplayedAt = null
backingExpiresAt = null
Formbricks.language = "default"
UpdateQueue.current.reset()
if(isUserIdDefined) {
Logger.d("User logged out successfully!")
}
}
private fun startSyncTimer() {

View File

@@ -0,0 +1,7 @@
package com.formbricks.formbrickssdk.model.enums
enum class SuccessType {
SET_USER_SUCCESS,
GET_ENVIRONMENT_SUCCESS,
LOGOUT_SUCCESS
}

View File

@@ -3,6 +3,21 @@ package com.formbricks.formbrickssdk.model.environment
import com.google.gson.annotations.SerializedName
import kotlinx.serialization.Serializable
@Serializable
data class SurveyLanguage(
@SerializedName("enabled") val enabled: Boolean,
@SerializedName("default") val default: Boolean,
@SerializedName("language") val language: LanguageDetail
)
@Serializable
data class LanguageDetail(
@SerializedName("id") val id: String,
@SerializedName("code") val code: String,
@SerializedName("alias") val alias: String?,
@SerializedName("projectId") val projectId: String
)
@Serializable
data class Survey(
@SerializedName("id") val id: String,
@@ -15,4 +30,5 @@ data class Survey(
@SerializedName("displayOption") val displayOption: String?,
@SerializedName("segment") val segment: Segment?,
@SerializedName("styling") val styling: Styling?,
@SerializedName("languages") val languages: List<SurveyLanguage>?
)

View File

@@ -1,7 +1,27 @@
package com.formbricks.formbrickssdk.model.error
import androidx.annotation.Keep
@Keep
object SDKError {
// Errors related to SDK initialization and configuration
val sdkIsNotInitialized = RuntimeException("Formbricks SDK is not initialized")
val sdkIsAlreadyInitialized = RuntimeException("Formbricks SDK is already initialized")
val fragmentManagerIsNotSet = RuntimeException("The fragment manager is not set.")
// Errors related to network and connectivity
val connectionIsNotAvailable = RuntimeException("There is no connection.")
}
val unableToLoadFormbicksJs = RuntimeException("Unable to load Formbricks Javascript package.")
// Errors related to surveys
val surveyDisplayFetchError =
RuntimeException("Error: creating display: TypeError: Failure to fetch the survey data.")
val surveyNotDisplayedError = RuntimeException("Survey was not displayed due to display percentage restrictions.")
val unableToRefreshEnvironment = RuntimeException("Unable to refresh environment state.")
val missingSurveyId = RuntimeException("Survey id is mandatory to set.")
val invalidDisplayOption = RuntimeException("Invalid Display Option.")
val unableToPostResponse = RuntimeException("Unable to post survey response.")
val surveyNotFoundError = RuntimeException("No survey found matching the action class.")
val noUserIdSetError = RuntimeException("No userId is set, please set a userId first using the setUserId function")
}

Some files were not shown because too many files have changed in this diff Show More