mirror of
https://github.com/formbricks/formbricks.git
synced 2026-02-03 21:59:38 -06:00
Compare commits
18 Commits
chore/upgr
...
v3.8.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40fa7a69c0 | ||
|
|
5eca30e513 | ||
|
|
4b78493782 | ||
|
|
2ce44b734f | ||
|
|
85d8f8c3ae | ||
|
|
3f16291137 | ||
|
|
a5958d5653 | ||
|
|
fdbdf8207a | ||
|
|
630e5489ec | ||
|
|
36943bb786 | ||
|
|
e1bbb0a10f | ||
|
|
27da540846 | ||
|
|
7d7f6ed04a | ||
|
|
ff01bc342d | ||
|
|
cd8b40b569 | ||
|
|
31c742f7a8 | ||
|
|
d6a7a2c21f | ||
|
|
499ecab691 |
26
.github/copilot-instructions.md
vendored
Normal file
26
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,26 @@
|
||||
# Testing Instructions
|
||||
|
||||
When generating test files inside the "/app/web" path, follow these rules:
|
||||
|
||||
- Use vitest
|
||||
- Ensure 100% code coverage
|
||||
- Add as few comments as possible
|
||||
- The test file should be located in the same folder as the original file
|
||||
- Use the `test` function instead of `it`
|
||||
- Follow the same test pattern used for other files in the package where the file is located
|
||||
- All imports should be at the top of the file, not inside individual tests
|
||||
- For mocking inside "test" blocks use "vi.mocked"
|
||||
- Add the original file path to the "test.coverage.include"array in the "apps/web/vite.config.mts" file
|
||||
- Don't mock functions that are already mocked in the "apps/web/vitestSetup.ts" file
|
||||
|
||||
If it's a test for a ".tsx" file, follow these extra instructions:
|
||||
|
||||
- Add this code inside the "describe" block and before any test:
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
- the "afterEach" function should only have "cleanup()" inside it and should be adde to the "vitest" imports
|
||||
- For click events, import userEvent from "@testing-library/user-event"
|
||||
- Mock other components that can make the text more complex and but at the same time mocking it wouldn't make the test flaky. It's ok to leave basic and simple components.
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,9 +1,4 @@
|
||||
{
|
||||
"github.copilot.chat.codeGeneration.instructions": [
|
||||
{
|
||||
"text": "When generating tests, always use vitest and use the `test` function instead of `it`."
|
||||
}
|
||||
],
|
||||
"javascript.updateImportsOnFileMove.enabled": "always",
|
||||
"sonarlint.connectedMode.project": {
|
||||
"connectionId": "formbricks",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:22-alpine3.20@sha256:40be979442621049f40b1d51a26b55e281246b5de4e5f51a18da7beb6e17e3f9 AS base
|
||||
FROM node:22-alpine3.21 AS base
|
||||
|
||||
#
|
||||
## step 1: Prune monorepo
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationAirtable, TIntegrationAirtableConfig } from "@formbricks/types/integration/airtable";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/integrations/airtable/components/AddIntegrationModal",
|
||||
() => ({
|
||||
AddIntegrationModal: ({ open, setOpenWithStates }) =>
|
||||
open ? (
|
||||
<div data-testid="add-modal">
|
||||
<button onClick={() => setOpenWithStates(false)}>close</button>
|
||||
</div>
|
||||
) : null,
|
||||
})
|
||||
);
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
environmentId: "env1",
|
||||
setIsConnected: vi.fn(),
|
||||
surveys: [],
|
||||
airtableArray: [],
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_new_table/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("open add modal", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/link_new_table/));
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("list integrations and open edit modal", async () => {
|
||||
const item = {
|
||||
baseId: "b",
|
||||
tableId: "t",
|
||||
surveyId: "s",
|
||||
surveyName: "S",
|
||||
tableName: "T",
|
||||
questions: "Q",
|
||||
questionIds: ["x"],
|
||||
createdAt: new Date(),
|
||||
includeVariables: false,
|
||||
includeHiddenFields: false,
|
||||
includeMetadata: false,
|
||||
includeCreatedAt: false,
|
||||
};
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [item] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(screen.getByTestId("add-modal")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
const { toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalled();
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
airtableIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] } as unknown as TIntegrationAirtableConfig,
|
||||
} as TIntegrationAirtable
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
const { toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -98,17 +98,17 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
{integrationData.length ? (
|
||||
<div className="mt-6 w-full rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-8 content-center rounded-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
{tableHeaders.map((header, idx) => (
|
||||
<div key={idx} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{tableHeaders.map((header) => (
|
||||
<div key={header} className={`col-span-2 hidden text-center sm:block`}>
|
||||
{t(header)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{integrationData.map((data, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.baseId}-${data.tableId}-${data.surveyId}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
setDefaultValues({
|
||||
base: data.baseId,
|
||||
@@ -129,7 +129,7 @@ export const ManageIntegration = (props: ManageIntegrationProps) => {
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), props.locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-hot-toast", () => ({
|
||||
default: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
locale: "en-US" as const,
|
||||
} as const;
|
||||
|
||||
describe("ManageIntegration (Google Sheets)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/no_integrations_yet/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_new_sheet/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("click link new sheet", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/link_new_sheet/));
|
||||
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("list integrations and open edit", async () => {
|
||||
const item = {
|
||||
spreadsheetId: "sid",
|
||||
spreadsheetName: "SheetName",
|
||||
surveyId: "s1",
|
||||
surveyName: "Survey1",
|
||||
questionIds: ["q1"],
|
||||
questions: "Q",
|
||||
createdAt: new Date(),
|
||||
};
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [item] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Survey1")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Survey1"));
|
||||
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({
|
||||
...item,
|
||||
index: 0,
|
||||
});
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
googleSheetIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { email: "a@b.com", data: [] },
|
||||
} as unknown as TIntegrationGoogleSheets
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -36,11 +36,10 @@ export const ManageIntegration = ({
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const integrationArray = googleSheetIntegration
|
||||
? googleSheetIntegration.config.data
|
||||
? googleSheetIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
let integrationArray: TIntegrationGoogleSheetsConfigData[] = [];
|
||||
if (googleSheetIntegration?.config.data) {
|
||||
integrationArray = googleSheetIntegration.config.data;
|
||||
}
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
@@ -112,9 +111,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-8 content-center rounded-lg hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.spreadsheetName}-${data.surveyName}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-8 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -124,7 +123,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type {
|
||||
TIntegrationNotion,
|
||||
TIntegrationNotionConfig,
|
||||
TIntegrationNotionConfigData,
|
||||
TIntegrationNotionCredential,
|
||||
} from "@formbricks/types/integration/notion";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("react-hot-toast", () => ({ success: vi.fn(), error: vi.fn() }));
|
||||
vi.mock("@/lib/time", () => ({ timeSince: () => "ago" }));
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("ManageIntegration", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
environment: {} as any,
|
||||
locale: "en-US" as const,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
handleNotionAuthorization: vi.fn(),
|
||||
};
|
||||
|
||||
test("shows empty state when no databases", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: {
|
||||
data: [] as TIntegrationNotionConfigData[],
|
||||
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||
} as TIntegrationNotionConfig,
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.notion.no_databases_found")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders list and handles clicks", async () => {
|
||||
const data = [
|
||||
{ surveyName: "S", databaseName: "D", createdAt: new Date().toISOString(), databaseId: "db" },
|
||||
] as unknown as TIntegrationNotionConfigData[];
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data, key: { workspace_name: "ws" } as TIntegrationNotionCredential },
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(defaultProps.setSelectedIntegration).toHaveBeenCalledWith({ ...data[0], index: 0 });
|
||||
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("update and link new buttons invoke handlers", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...defaultProps}
|
||||
notionIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: {
|
||||
data: [],
|
||||
key: { workspace_name: "ws" } as TIntegrationNotionCredential,
|
||||
} as TIntegrationNotionConfig,
|
||||
} as TIntegrationNotion
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("environments.integrations.notion.update_connection"));
|
||||
expect(defaultProps.handleNotionAuthorization).toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByText("environments.integrations.notion.link_new_database"));
|
||||
expect(defaultProps.setOpenAddIntegrationModal).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -39,11 +39,11 @@ export const ManageIntegration = ({
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const integrationArray = notionIntegration
|
||||
? notionIntegration.config.data
|
||||
? notionIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
|
||||
let integrationArray: TIntegrationNotionConfigData[] = [];
|
||||
if (notionIntegration?.config.data) {
|
||||
integrationArray = notionIntegration.config.data;
|
||||
}
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
@@ -121,9 +121,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 cursor-pointer grid-cols-6 content-center rounded-lg hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.databaseId}`}
|
||||
className="grid h-16 w-full cursor-pointer grid-cols-6 content-center rounded-lg p-2 hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -132,7 +132,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/integrations/actions";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TIntegrationSlack, TIntegrationSlackConfigData } from "@formbricks/types/integration/slack";
|
||||
import { ManageIntegration } from "./ManageIntegration";
|
||||
|
||||
vi.mock("@/app/(app)/environments/[environmentId]/integrations/actions", () => ({
|
||||
deleteIntegrationAction: vi.fn(),
|
||||
}));
|
||||
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn(), error: vi.fn() } }));
|
||||
vi.mock("@/modules/ui/components/delete-dialog", () => ({
|
||||
DeleteDialog: ({ open, setOpen, onDelete }: any) =>
|
||||
open ? (
|
||||
<div data-testid="delete-dialog">
|
||||
<button onClick={onDelete}>confirm</button>
|
||||
<button onClick={() => setOpen(false)}>cancel</button>
|
||||
</div>
|
||||
) : null,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/empty-space-filler", () => ({
|
||||
EmptySpaceFiller: ({ emptyMessage }: any) => <div>{emptyMessage}</div>,
|
||||
}));
|
||||
|
||||
const baseProps = {
|
||||
environment: { id: "env1" } as TEnvironment,
|
||||
setOpenAddIntegrationModal: vi.fn(),
|
||||
setIsConnected: vi.fn(),
|
||||
setSelectedIntegration: vi.fn(),
|
||||
refreshChannels: vi.fn(),
|
||||
handleSlackAuthorization: vi.fn(),
|
||||
showReconnectButton: false,
|
||||
locale: "en-US" as const,
|
||||
};
|
||||
|
||||
describe("ManageIntegration (Slack)", () => {
|
||||
afterEach(() => cleanup());
|
||||
|
||||
test("empty state", () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/connect_your_first_slack_channel/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/link_channel/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("link channel triggers handlers", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/link_channel/));
|
||||
expect(baseProps.refreshChannels).toHaveBeenCalled();
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith(null);
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("show reconnect button and triggers authorization", async () => {
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
showReconnectButton={true}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "Team" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.integrations.slack.slack_reconnect_button")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("environments.integrations.slack.slack_reconnect_button"));
|
||||
expect(baseProps.handleSlackAuthorization).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("list integrations and open edit", async () => {
|
||||
const item = {
|
||||
surveyName: "S",
|
||||
channelName: "C",
|
||||
questions: "Q",
|
||||
createdAt: new Date().toISOString(),
|
||||
surveyId: "s",
|
||||
channelId: "c",
|
||||
} as unknown as TIntegrationSlackConfigData;
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [item], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("S")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("S"));
|
||||
expect(baseProps.setSelectedIntegration).toHaveBeenCalledWith({ ...item, index: 0 });
|
||||
expect(baseProps.setOpenAddIntegrationModal).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
test("delete integration success", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ data: true } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
expect(screen.getByTestId("delete-dialog")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
expect(deleteIntegrationAction).toHaveBeenCalledWith({ integrationId: "1" });
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.success).toHaveBeenCalledWith("environments.integrations.integration_removed_successfully");
|
||||
expect(baseProps.setIsConnected).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test("delete integration error", async () => {
|
||||
vi.mocked(deleteIntegrationAction).mockResolvedValue({ error: "fail" } as any);
|
||||
render(
|
||||
<ManageIntegration
|
||||
{...baseProps}
|
||||
slackIntegration={
|
||||
{
|
||||
id: "1",
|
||||
config: { data: [], key: { team: { name: "team name" } } },
|
||||
} as unknown as TIntegrationSlack
|
||||
}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText(/delete_integration/));
|
||||
await userEvent.click(screen.getByText("confirm"));
|
||||
const { default: toast } = await import("react-hot-toast");
|
||||
expect(toast.error).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
@@ -6,8 +6,7 @@ import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptySpaceFiller } from "@/modules/ui/components/empty-space-filler";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { T } from "@tolgee/react";
|
||||
import { T, useTranslate } from "@tolgee/react";
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
@@ -43,11 +42,10 @@ export const ManageIntegration = ({
|
||||
const { t } = useTranslate();
|
||||
const [isDeleteIntegrationModalOpen, setIsDeleteIntegrationModalOpen] = useState(false);
|
||||
const [isDeleting, setisDeleting] = useState(false);
|
||||
const integrationArray = slackIntegration
|
||||
? slackIntegration.config.data
|
||||
? slackIntegration.config.data
|
||||
: []
|
||||
: [];
|
||||
let integrationArray: TIntegrationSlackConfigData[] = [];
|
||||
if (slackIntegration?.config.data) {
|
||||
integrationArray = slackIntegration.config.data;
|
||||
}
|
||||
|
||||
const handleDeleteIntegration = async () => {
|
||||
setisDeleting(true);
|
||||
@@ -129,9 +127,9 @@ export const ManageIntegration = ({
|
||||
{integrationArray &&
|
||||
integrationArray.map((data, index) => {
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className="m-2 grid h-16 grid-cols-8 content-center rounded-lg text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
<button
|
||||
key={`${index}-${data.surveyName}-${data.channelName}`}
|
||||
className="grid h-16 w-full grid-cols-8 content-center rounded-lg p-2 text-slate-700 hover:cursor-pointer hover:bg-slate-100"
|
||||
onClick={() => {
|
||||
editIntegration(index);
|
||||
}}>
|
||||
@@ -141,7 +139,7 @@ export const ManageIntegration = ({
|
||||
<div className="col-span-2 text-center">
|
||||
{timeSince(data.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Cell, Row } from "@tanstack/react-table";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import type { TResponse, TResponseTableData } from "@formbricks/types/responses";
|
||||
import { ResponseTableCell } from "./ResponseTableCell";
|
||||
|
||||
const makeCell = (
|
||||
id: string,
|
||||
size = 100,
|
||||
first = false,
|
||||
last = false,
|
||||
content = "CellContent"
|
||||
): Cell<TResponseTableData, unknown> =>
|
||||
({
|
||||
column: {
|
||||
id,
|
||||
getSize: () => size,
|
||||
getIsFirstColumn: () => first,
|
||||
getIsLastColumn: () => last,
|
||||
getStart: () => 0,
|
||||
columnDef: { cell: () => content },
|
||||
},
|
||||
id,
|
||||
getContext: () => ({}),
|
||||
}) as unknown as Cell<TResponseTableData, unknown>;
|
||||
|
||||
const makeRow = (id: string, selected = false): Row<TResponseTableData> =>
|
||||
({ id, getIsSelected: () => selected }) as unknown as Row<TResponseTableData>;
|
||||
|
||||
describe("ResponseTableCell", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders cell content", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("CellContent")).toBeDefined();
|
||||
});
|
||||
|
||||
test("calls setSelectedResponseId on cell click when not select column", async () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r1" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("CellContent"));
|
||||
expect(setSel).toHaveBeenCalledWith("r1");
|
||||
});
|
||||
|
||||
test("does not call setSelectedResponseId on select column click", async () => {
|
||||
const cell = makeCell("select");
|
||||
const row = makeRow("r1");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r1" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
await userEvent.click(screen.getByText("CellContent"));
|
||||
expect(setSel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("renders maximize icon for createdAt column and handles click", async () => {
|
||||
const cell = makeCell("createdAt", 120, false, false);
|
||||
const row = makeRow("r2");
|
||||
const setSel = vi.fn();
|
||||
render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={setSel}
|
||||
responses={[{ id: "r2" } as TResponse]}
|
||||
/>
|
||||
);
|
||||
const btn = screen.getByRole("button", { name: /expand response/i });
|
||||
expect(btn).toBeDefined();
|
||||
await userEvent.click(btn);
|
||||
expect(setSel).toHaveBeenCalledWith("r2");
|
||||
});
|
||||
|
||||
test("does not apply selected style when row.getIsSelected() is false", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1", false);
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).not.toHaveClass("bg-slate-100");
|
||||
});
|
||||
|
||||
test("applies selected style when row.getIsSelected() is true", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1", true);
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
expect(container.firstChild).toHaveClass("bg-slate-100");
|
||||
});
|
||||
|
||||
test("renders collapsed height class when isExpanded is false", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={false}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
const inner = container.querySelector("div > div");
|
||||
expect(inner).toHaveClass("h-10");
|
||||
});
|
||||
|
||||
test("renders expanded height class when isExpanded is true", () => {
|
||||
const cell = makeCell("col1");
|
||||
const row = makeRow("r1");
|
||||
const { container } = render(
|
||||
<ResponseTableCell
|
||||
cell={cell}
|
||||
row={row}
|
||||
isExpanded={true}
|
||||
setSelectedResponseId={vi.fn()}
|
||||
responses={[]}
|
||||
/>
|
||||
);
|
||||
const inner = container.querySelector("div > div");
|
||||
expect(inner).toHaveClass("h-full");
|
||||
});
|
||||
});
|
||||
@@ -35,11 +35,13 @@ export const ResponseTableCell = ({
|
||||
|
||||
// Conditional rendering of maximize icon
|
||||
const renderMaximizeIcon = cell.column.id === "createdAt" && (
|
||||
<div
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300"
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Expand response"
|
||||
className="hidden flex-shrink-0 cursor-pointer items-center rounded-md border border-slate-200 bg-white p-2 group-hover:flex hover:border-slate-300 focus:outline-none"
|
||||
onClick={handleCellClick}>
|
||||
<Maximize2Icon className="h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyConsentQuestion,
|
||||
TSurveyQuestionSummaryConsent,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { ConsentSummary } from "./ConsentSummary";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
|
||||
() => ({
|
||||
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("ConsentSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockSetFilter = vi.fn();
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
headline: { en: "Headline" },
|
||||
type: TSurveyQuestionTypeEnum.Consent,
|
||||
} as unknown as TSurveyConsentQuestion,
|
||||
accepted: { percentage: 60.5, count: 61 },
|
||||
dismissed: { percentage: 39.5, count: 40 },
|
||||
} as unknown as TSurveyQuestionSummaryConsent;
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
test("renders accepted and dismissed with correct values", () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
expect(screen.getByText("common.accepted")).toBeInTheDocument();
|
||||
expect(screen.getByText(/60\.5%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/61/)).toBeInTheDocument();
|
||||
expect(screen.getByText("common.dismissed")).toBeInTheDocument();
|
||||
expect(screen.getByText(/39\.5%/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/40/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on accepted click", async () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
await userEvent.click(screen.getByText("common.accepted"));
|
||||
expect(mockSetFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
{ en: "Headline" },
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
"is",
|
||||
"common.accepted"
|
||||
);
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on dismissed click", async () => {
|
||||
render(<ConsentSummary questionSummary={questionSummary} survey={survey} setFilter={mockSetFilter} />);
|
||||
await userEvent.click(screen.getByText("common.dismissed"));
|
||||
expect(mockSetFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
{ en: "Headline" },
|
||||
TSurveyQuestionTypeEnum.Consent,
|
||||
"is",
|
||||
"common.dismissed"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders singular and plural response labels", () => {
|
||||
const oneAndTwo = {
|
||||
...questionSummary,
|
||||
accepted: { percentage: questionSummary.accepted.percentage, count: 1 },
|
||||
dismissed: { percentage: questionSummary.dismissed.percentage, count: 2 },
|
||||
};
|
||||
render(<ConsentSummary questionSummary={oneAndTwo} survey={survey} setFilter={mockSetFilter} />);
|
||||
expect(screen.getByText(/1 common\.response/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 common\.responses/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -41,11 +41,11 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{summaryItems.map((summaryItem) => {
|
||||
return (
|
||||
<div
|
||||
className="group cursor-pointer"
|
||||
<button
|
||||
className="group w-full cursor-pointer"
|
||||
key={summaryItem.title}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -74,7 +74,7 @@ export const ConsentSummary = ({ questionSummary, survey, setFilter }: ConsentSu
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={summaryItem.percentage / 100} />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MatrixQuestionSummary } from "./MatrixQuestionSummary";
|
||||
|
||||
vi.mock(
|
||||
"@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/QuestionSummaryHeader",
|
||||
() => ({
|
||||
QuestionSummaryHeader: () => <div>QuestionSummaryHeader</div>,
|
||||
})
|
||||
);
|
||||
|
||||
describe("MatrixQuestionSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const survey = { id: "s1" } as any;
|
||||
const questionSummary = {
|
||||
question: { id: "q1", headline: "Q Head", type: "matrix" },
|
||||
data: [
|
||||
{
|
||||
rowLabel: "Row1",
|
||||
totalResponsesForRow: 10,
|
||||
columnPercentages: [
|
||||
{ column: "Yes", percentage: 50 },
|
||||
{ column: "No", percentage: 50 },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
|
||||
test("renders headers and buttons, click triggers setFilter", async () => {
|
||||
const setFilter = vi.fn();
|
||||
render(<MatrixQuestionSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
|
||||
|
||||
// column headers
|
||||
expect(screen.getByText("Yes")).toBeInTheDocument();
|
||||
expect(screen.getByText("No")).toBeInTheDocument();
|
||||
// row label
|
||||
expect(screen.getByText("Row1")).toBeInTheDocument();
|
||||
// buttons
|
||||
const btn = screen.getAllByRole("button", { name: /50/ });
|
||||
await userEvent.click(btn[0]);
|
||||
expect(setFilter).toHaveBeenCalledWith("q1", "Q Head", "matrix", "Row1", "Yes");
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<table className="mx-auto border-collapse cursor-default text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="p-4 pb-3 pt-0 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
<th className="p-4 pt-0 pb-3 font-medium text-slate-400 dark:border-slate-600 dark:text-slate-200"></th>
|
||||
{columns.map((column) => (
|
||||
<th key={column} className="text-center font-medium">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(column)} shouldRender={true}>
|
||||
@@ -65,7 +65,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
<tbody>
|
||||
{questionSummary.data.map(({ rowLabel, columnPercentages }, rowIndex) => (
|
||||
<tr key={rowLabel}>
|
||||
<td className="max-w-60 overflow-hidden text-ellipsis whitespace-nowrap p-4">
|
||||
<td className="max-w-60 overflow-hidden p-4 text-ellipsis whitespace-nowrap">
|
||||
<TooltipRenderer tooltipContent={getTooltipContent(rowLabel)} shouldRender={true}>
|
||||
<p className="max-w-40 overflow-hidden text-ellipsis whitespace-nowrap">{rowLabel}</p>
|
||||
</TooltipRenderer>
|
||||
@@ -81,7 +81,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
percentage,
|
||||
questionSummary.data[rowIndex].totalResponsesForRow
|
||||
)}>
|
||||
<div
|
||||
<button
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
onClick={() =>
|
||||
@@ -94,7 +94,7 @@ export const MatrixQuestionSummary = ({ questionSummary, survey, setFilter }: Ma
|
||||
)
|
||||
}>
|
||||
{percentage}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipRenderer>
|
||||
</td>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { MultipleChoiceSummary } from "./MultipleChoiceSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/avatars", () => ({
|
||||
PersonAvatar: ({ personId }: any) => <div data-testid="avatar">{personId}</div>,
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({ QuestionSummaryHeader: () => <div data-testid="header" /> }));
|
||||
|
||||
describe("MultipleChoiceSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseSurvey = { id: "s1" } as any;
|
||||
const envId = "env";
|
||||
|
||||
test("renders header and choice button", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q",
|
||||
headline: "H",
|
||||
type: "multipleChoiceSingle",
|
||||
choices: [{ id: "c", label: { default: "C" } }],
|
||||
},
|
||||
choices: { C: { value: "C", count: 1, percentage: 100, others: [] } },
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("header")).toBeDefined();
|
||||
const btn = screen.getByText("1 - C");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q",
|
||||
"H",
|
||||
"multipleChoiceSingle",
|
||||
"environments.surveys.summary.includes_either",
|
||||
["C"]
|
||||
);
|
||||
});
|
||||
|
||||
test("renders others and load more for link", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const others = Array.from({ length: 12 }, (_, i) => ({
|
||||
value: `O${i}`,
|
||||
contact: { id: `id${i}` },
|
||||
contactAttributes: {},
|
||||
}));
|
||||
const q = {
|
||||
question: {
|
||||
id: "q2",
|
||||
headline: "H2",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [{ id: "c2", label: { default: "X" } }],
|
||||
},
|
||||
choices: { X: { value: "X", count: 0, percentage: 0, others } },
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 5,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("environments.surveys.summary.other_values_found")).toBeDefined();
|
||||
expect(screen.getAllByText(/^O/)).toHaveLength(10);
|
||||
await userEvent.click(screen.getByText("common.load_more"));
|
||||
expect(screen.getAllByText(/^O/)).toHaveLength(12);
|
||||
});
|
||||
|
||||
test("renders others with avatar for app", () => {
|
||||
const setFilter = vi.fn();
|
||||
const others = [{ value: "Val", contact: { id: "uid" }, contactAttributes: {} }];
|
||||
const q = {
|
||||
question: {
|
||||
id: "q3",
|
||||
headline: "H3",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [{ id: "c3", label: { default: "L" } }],
|
||||
},
|
||||
choices: { L: { value: "L", count: 0, percentage: 0, others } },
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 1,
|
||||
} as any;
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="app"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByTestId("avatar")).toBeDefined();
|
||||
expect(screen.getByText("Val")).toBeDefined();
|
||||
});
|
||||
|
||||
test("places choice without others before one with others", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
A: { value: "A", count: 0, percentage: 0, others: [] },
|
||||
B: { value: "B", count: 0, percentage: 0, others: [{ value: "x" }] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - A");
|
||||
expect(btns[1]).toHaveTextContent("1 - B");
|
||||
});
|
||||
|
||||
test("sorts by count when neither has others", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
X: { value: "X", count: 1, percentage: 50, others: [] },
|
||||
Y: { value: "Y", count: 2, percentage: 50, others: [] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - Y50%2 common.selections");
|
||||
expect(btns[1]).toHaveTextContent("1 - X50%1 common.selection");
|
||||
});
|
||||
|
||||
test("places choice with others after one without when reversed inputs", () => {
|
||||
const setFilter = vi.fn();
|
||||
const choices = {
|
||||
C: { value: "C", count: 1, percentage: 0, others: [{ value: "z" }] },
|
||||
D: { value: "D", count: 1, percentage: 0, others: [] },
|
||||
};
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={
|
||||
{
|
||||
question: { id: "q", headline: "", type: "multipleChoiceSingle", choices: [] },
|
||||
choices,
|
||||
type: "multipleChoiceSingle",
|
||||
selectionCount: 0,
|
||||
} as any
|
||||
}
|
||||
environmentId="e"
|
||||
surveyType="link"
|
||||
survey={{} as any}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
const btns = screen.getAllByRole("button");
|
||||
expect(btns[0]).toHaveTextContent("2 - D");
|
||||
expect(btns[1]).toHaveTextContent("1 - C");
|
||||
});
|
||||
|
||||
test("multi type non-other uses includes_all", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q4",
|
||||
headline: "H4",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [
|
||||
{ id: "other", label: { default: "O" } },
|
||||
{ id: "c4", label: { default: "C4" } },
|
||||
],
|
||||
},
|
||||
choices: {
|
||||
O: { value: "O", count: 1, percentage: 10, others: [] },
|
||||
C4: { value: "C4", count: 2, percentage: 20, others: [] },
|
||||
},
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByText("2 - C4");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q4",
|
||||
"H4",
|
||||
"multipleChoiceMulti",
|
||||
"environments.surveys.summary.includes_all",
|
||||
["C4"]
|
||||
);
|
||||
});
|
||||
|
||||
test("multi type other uses includes_either", async () => {
|
||||
const setFilter = vi.fn();
|
||||
const q = {
|
||||
question: {
|
||||
id: "q5",
|
||||
headline: "H5",
|
||||
type: "multipleChoiceMulti",
|
||||
choices: [
|
||||
{ id: "other", label: { default: "O5" } },
|
||||
{ id: "c5", label: { default: "C5" } },
|
||||
],
|
||||
},
|
||||
choices: {
|
||||
O5: { value: "O5", count: 1, percentage: 10, others: [] },
|
||||
C5: { value: "C5", count: 0, percentage: 0, others: [] },
|
||||
},
|
||||
type: "multipleChoiceMulti",
|
||||
selectionCount: 0,
|
||||
} as any;
|
||||
|
||||
render(
|
||||
<MultipleChoiceSummary
|
||||
questionSummary={q}
|
||||
environmentId={envId}
|
||||
surveyType="link"
|
||||
survey={baseSurvey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
|
||||
const btn = screen.getByText("2 - O5");
|
||||
await userEvent.click(btn);
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q5",
|
||||
"H5",
|
||||
"multipleChoiceMulti",
|
||||
"environments.surveys.summary.includes_either",
|
||||
["O5"]
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,7 +7,7 @@ import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { useTranslate } from "@tolgee/react";
|
||||
import { InboxIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { Fragment, useState } from "react";
|
||||
import {
|
||||
TI18nString,
|
||||
TSurvey,
|
||||
@@ -45,10 +45,15 @@ export const MultipleChoiceSummary = ({
|
||||
const otherValue = questionSummary.question.choices.find((choice) => choice.id === "other")?.label.default;
|
||||
// sort by count and transform to array
|
||||
const results = Object.values(questionSummary.choices).sort((a, b) => {
|
||||
if (a.others) return 1; // Always put a after b if a has 'others'
|
||||
if (b.others) return -1; // Always put b after a if b has 'others'
|
||||
const aHasOthers = (a.others?.length ?? 0) > 0;
|
||||
const bHasOthers = (b.others?.length ?? 0) > 0;
|
||||
|
||||
return b.count - a.count; // Sort by count
|
||||
// if one has “others” and the other doesn’t, push the one with others to the end
|
||||
if (aHasOthers && !bHasOthers) return 1;
|
||||
if (!aHasOthers && bHasOthers) return -1;
|
||||
|
||||
// if they’re “tied” on having others, fall back to count
|
||||
return b.count - a.count;
|
||||
});
|
||||
|
||||
const handleLoadMore = (e: React.MouseEvent) => {
|
||||
@@ -80,40 +85,41 @@ export const MultipleChoiceSummary = ({
|
||||
/>
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, resultsIdx) => (
|
||||
<div
|
||||
key={result.value}
|
||||
className="group cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
<Fragment key={result.value}>
|
||||
<button
|
||||
className="group w-full cursor-pointer"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
questionSummary.question.id,
|
||||
questionSummary.question.headline,
|
||||
questionSummary.question.type,
|
||||
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
|
||||
? t("environments.surveys.summary.includes_either")
|
||||
: t("environments.surveys.summary.includes_all"),
|
||||
[result.value]
|
||||
)
|
||||
}>
|
||||
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
|
||||
<div className="mr-8 flex w-full justify-between space-x-1 sm:justify-normal">
|
||||
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
|
||||
{results.length - resultsIdx} - {result.value}
|
||||
</p>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
</div>
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
{result.others && result.others.length > 0 && (
|
||||
<div className="mt-4 rounded-lg border border-slate-200" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mt-4 rounded-lg border border-slate-200">
|
||||
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">
|
||||
{t("environments.surveys.summary.other_values_found")}
|
||||
@@ -124,11 +130,9 @@ export const MultipleChoiceSummary = ({
|
||||
.filter((otherValue) => otherValue.value !== "")
|
||||
.slice(0, visibleOtherResponses)
|
||||
.map((otherValue, idx) => (
|
||||
<div key={idx} dir="auto">
|
||||
<div key={`${idx}-${otherValue}`} dir="auto">
|
||||
{surveyType === "link" && (
|
||||
<div
|
||||
key={idx}
|
||||
className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -139,7 +143,6 @@ export const MultipleChoiceSummary = ({
|
||||
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
|
||||
: { pathname: null }
|
||||
}
|
||||
key={idx}
|
||||
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
|
||||
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
|
||||
<span>{otherValue.value}</span>
|
||||
@@ -163,7 +166,7 @@ export const MultipleChoiceSummary = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryNps } from "@formbricks/types/surveys/types";
|
||||
import { NPSSummary } from "./NPSSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress, barColor }: { progress: number; barColor: string }) => (
|
||||
<div data-testid="progress-bar">{`${progress}-${barColor}`}</div>
|
||||
),
|
||||
HalfCircle: ({ value }: { value: number }) => <div data-testid="half-circle">{value}</div>,
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: () => <div data-testid="question-summary-header" />,
|
||||
}));
|
||||
|
||||
describe("NPSSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const baseQuestion = { id: "q1", headline: "Question?", type: "nps" as const };
|
||||
const summary = {
|
||||
question: baseQuestion,
|
||||
promoters: { count: 2, percentage: 50 },
|
||||
passives: { count: 1, percentage: 25 },
|
||||
detractors: { count: 1, percentage: 25 },
|
||||
dismissed: { count: 0, percentage: 0 },
|
||||
score: 25,
|
||||
} as unknown as TSurveyQuestionSummaryNps;
|
||||
const survey = {} as any;
|
||||
|
||||
test("renders header, groups, ProgressBar and HalfCircle", () => {
|
||||
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={() => {}} />);
|
||||
expect(screen.getByTestId("question-summary-header")).toBeDefined();
|
||||
["promoters", "passives", "detractors", "dismissed"].forEach((g) =>
|
||||
expect(screen.getByText(g)).toBeDefined()
|
||||
);
|
||||
expect(screen.getAllByTestId("progress-bar")[0]).toBeDefined();
|
||||
expect(screen.getByTestId("half-circle")).toHaveTextContent("25");
|
||||
});
|
||||
|
||||
test.each([
|
||||
["promoters", "environments.surveys.summary.includes_either", ["9", "10"]],
|
||||
["passives", "environments.surveys.summary.includes_either", ["7", "8"]],
|
||||
["detractors", "environments.surveys.summary.is_less_than", "7"],
|
||||
["dismissed", "common.skipped", undefined],
|
||||
])("clicking %s calls setFilter correctly", async (group, cmp, vals) => {
|
||||
const setFilter = vi.fn();
|
||||
render(<NPSSummary questionSummary={summary} survey={survey} setFilter={setFilter} />);
|
||||
await userEvent.click(screen.getByText(group));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
baseQuestion.id,
|
||||
baseQuestion.headline,
|
||||
baseQuestion.type,
|
||||
cmp,
|
||||
vals
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -62,14 +62,17 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
|
||||
<div className="cursor-pointer hover:opacity-80" key={group} onClick={() => applyFilter(group)}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={group}
|
||||
onClick={() => applyFilter(group)}>
|
||||
<div
|
||||
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
|
||||
<div className="mr-8 flex space-x-1">
|
||||
<p
|
||||
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
className={`font-semibold text-slate-700 capitalize ${group === "dismissed" ? "" : "text-slate-700"}`}>
|
||||
{group}
|
||||
</p>
|
||||
<div>
|
||||
@@ -87,11 +90,11 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
|
||||
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
|
||||
progress={questionSummary[group]?.percentage / 100}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center pb-4 pt-4">
|
||||
<div className="flex justify-center pt-4 pb-4">
|
||||
<HalfCircle value={questionSummary.score} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
import { PictureChoiceSummary } from "./PictureChoiceSummary";
|
||||
|
||||
vi.mock("@/modules/ui/components/progress-bar", () => ({
|
||||
ProgressBar: ({ progress }: { progress: number }) => (
|
||||
<div data-testid="progress-bar" data-progress={progress} />
|
||||
),
|
||||
}));
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
// mock next image
|
||||
vi.mock("next/image", () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
default: ({ src }: { src: string }) => <img src={src} alt="" />,
|
||||
}));
|
||||
|
||||
const survey = {} as TSurvey;
|
||||
|
||||
describe("PictureChoiceSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders choices with formatted percentages and counts", () => {
|
||||
const choices = [
|
||||
{ id: "1", imageUrl: "img1.png", percentage: 33.3333, count: 1 },
|
||||
{ id: "2", imageUrl: "img2.png", percentage: 66.6667, count: 2 },
|
||||
];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: { id: "q1", type: TSurveyQuestionTypeEnum.PictureSelection, headline: "H", allowMulti: true },
|
||||
selectionCount: 3,
|
||||
} as any;
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
|
||||
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
expect(screen.getByText("33.33%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1 common.selection")).toBeInTheDocument();
|
||||
expect(screen.getByText("2 common.selections")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("progress-bar")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("calls setFilter with correct args on click", async () => {
|
||||
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 25, count: 10 }];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: "H1",
|
||||
allowMulti: true,
|
||||
},
|
||||
selectionCount: 10,
|
||||
} as any;
|
||||
const setFilter = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={setFilter} />);
|
||||
|
||||
await user.click(screen.getByRole("button"));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
"H1",
|
||||
TSurveyQuestionTypeEnum.PictureSelection,
|
||||
"environments.surveys.summary.includes_all",
|
||||
["environments.surveys.edit.picture_idx"]
|
||||
);
|
||||
});
|
||||
|
||||
test("hides additionalInfo when allowMulti is false", () => {
|
||||
const choices = [{ id: "1", imageUrl: "img1.png", percentage: 50, count: 5 }];
|
||||
const questionSummary = {
|
||||
choices,
|
||||
question: {
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.PictureSelection,
|
||||
headline: "H2",
|
||||
allowMulti: false,
|
||||
},
|
||||
selectionCount: 5,
|
||||
} as any;
|
||||
render(<PictureChoiceSummary questionSummary={questionSummary} survey={survey} setFilter={() => {}} />);
|
||||
|
||||
expect(screen.getByTestId("header")).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -43,10 +43,10 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{results.map((result, index) => (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.id}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -79,7 +79,7 @@ export const PictureChoiceSummary = ({ questionSummary, survey, setFilter }: Pic
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100 || 0} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyQuestionSummaryRating } from "@formbricks/types/surveys/types";
|
||||
import { RatingSummary } from "./RatingSummary";
|
||||
|
||||
vi.mock("./QuestionSummaryHeader", () => ({
|
||||
QuestionSummaryHeader: ({ additionalInfo }: any) => <div data-testid="header">{additionalInfo}</div>,
|
||||
}));
|
||||
|
||||
describe("RatingSummary", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders overall average and choices", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "star",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 3.1415,
|
||||
choices: [
|
||||
{ rating: 1, percentage: 50, count: 2 },
|
||||
{ rating: 2, percentage: 50, count: 3 },
|
||||
],
|
||||
dismissed: { count: 0 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
expect(screen.getByText("environments.surveys.summary.overall: 3.14")).toBeDefined();
|
||||
expect(screen.getAllByRole("button")).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("clicking a choice calls setFilter with correct args", async () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "number",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 2,
|
||||
choices: [{ rating: 3, percentage: 100, count: 1 }],
|
||||
dismissed: { count: 0 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(setFilter).toHaveBeenCalledWith(
|
||||
"q1",
|
||||
"Headline",
|
||||
"rating",
|
||||
"environments.surveys.summary.is_equal_to",
|
||||
"3"
|
||||
);
|
||||
});
|
||||
|
||||
test("renders dismissed section when dismissed count > 0", () => {
|
||||
const questionSummary = {
|
||||
question: {
|
||||
id: "q1",
|
||||
scale: "smiley",
|
||||
headline: "Headline",
|
||||
type: "rating",
|
||||
range: [1, 5],
|
||||
isColorCodingEnabled: false,
|
||||
},
|
||||
average: 4,
|
||||
choices: [],
|
||||
dismissed: { count: 1 },
|
||||
} as unknown as TSurveyQuestionSummaryRating;
|
||||
const survey = {};
|
||||
const setFilter = vi.fn();
|
||||
render(<RatingSummary questionSummary={questionSummary} survey={survey as any} setFilter={setFilter} />);
|
||||
expect(screen.getByText("common.dismissed")).toBeDefined();
|
||||
expect(screen.getByText("1 common.response")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -50,10 +50,10 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
|
||||
<div className="space-y-5 px-4 pt-4 pb-6 text-sm md:px-6 md:text-base">
|
||||
{questionSummary.choices.map((result) => (
|
||||
<div
|
||||
className="cursor-pointer hover:opacity-80"
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
key={result.rating}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
@@ -85,7 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{questionSummary.dismissed && questionSummary.dismissed.count > 0 && (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
vi.mock("lucide-react", () => ({
|
||||
ChevronDownIcon: () => <div data-testid="down" />,
|
||||
ChevronUpIcon: () => <div data-testid="up" />,
|
||||
}));
|
||||
vi.mock("@/modules/ui/components/tooltip", () => ({
|
||||
TooltipProvider: ({ children }) => <>{children}</>,
|
||||
Tooltip: ({ children }) => <>{children}</>,
|
||||
TooltipTrigger: ({ children }) => <>{children}</>,
|
||||
TooltipContent: ({ children }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
const baseSummary = {
|
||||
completedPercentage: 50,
|
||||
completedResponses: 2,
|
||||
displayCount: 3,
|
||||
dropOffPercentage: 25,
|
||||
dropOffCount: 1,
|
||||
startsPercentage: 75,
|
||||
totalResponses: 4,
|
||||
ttcAverage: 65000,
|
||||
};
|
||||
|
||||
describe("SummaryMetadata", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders loading skeletons when isLoading=true", () => {
|
||||
const { container } = render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={true}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container.getElementsByClassName("animate-pulse")).toHaveLength(5);
|
||||
});
|
||||
|
||||
test("renders all stats and formats time correctly, toggles dropOffs icon", async () => {
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
surveySummary={baseSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
// impressions, starts, completed, drop_offs, ttc
|
||||
expect(screen.getByText("environments.surveys.summary.impressions")).toBeInTheDocument();
|
||||
expect(screen.getByText("3")).toBeInTheDocument();
|
||||
expect(screen.getByText("75%")).toBeInTheDocument();
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("50%")).toBeInTheDocument();
|
||||
expect(screen.getByText("2")).toBeInTheDocument();
|
||||
expect(screen.getByText("25%")).toBeInTheDocument();
|
||||
expect(screen.getByText("1")).toBeInTheDocument();
|
||||
expect(screen.getByText("1m 5.00s")).toBeInTheDocument();
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("formats time correctly when < 60 seconds", () => {
|
||||
const smallSummary = { ...baseSummary, ttcAverage: 5000 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={smallSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText("5.00s")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders '-' for dropOffCount=0 and still toggles icon", async () => {
|
||||
const zeroSummary = { ...baseSummary, dropOffCount: 0 };
|
||||
const Wrapper = () => {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<SummaryMetadata
|
||||
showDropOffs={show}
|
||||
setShowDropOffs={setShow}
|
||||
surveySummary={zeroSummary}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
render(<Wrapper />);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
const btn = screen.getByRole("button");
|
||||
expect(screen.queryByTestId("down")).toBeInTheDocument();
|
||||
await userEvent.click(btn);
|
||||
expect(screen.queryByTestId("up")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("renders '-' for displayCount=0", () => {
|
||||
const dispZero = { ...baseSummary, displayCount: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={dispZero}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("renders '-' for totalResponses=0", () => {
|
||||
const totZero = { ...baseSummary, totalResponses: 0 };
|
||||
render(
|
||||
<SummaryMetadata
|
||||
showDropOffs={false}
|
||||
setShowDropOffs={() => {}}
|
||||
surveySummary={totZero}
|
||||
isLoading={false}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText("-")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -71,6 +71,8 @@ export const SummaryMetadata = ({
|
||||
ttcAverage,
|
||||
} = surveySummary;
|
||||
const { t } = useTranslate();
|
||||
const displayCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 gap-4 md:grid-cols-5 md:gap-x-2 lg:col-span-4">
|
||||
@@ -99,9 +101,7 @@ export const SummaryMetadata = ({
|
||||
<TooltipProvider delayDuration={50}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}
|
||||
className="group flex h-full w-full cursor-pointer flex-col justify-between space-y-2 rounded-lg border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<div className="flex h-full cursor-default flex-col justify-between space-y-2 rounded-xl border border-slate-200 bg-white p-4 text-left shadow-sm">
|
||||
<span className="text-sm text-slate-600">
|
||||
{t("environments.surveys.summary.drop_offs")}
|
||||
{`${Math.round(dropOffPercentage)}%` !== "NaN%" && !isLoading && (
|
||||
@@ -112,20 +112,20 @@ export const SummaryMetadata = ({
|
||||
<span className="text-2xl font-bold text-slate-800">
|
||||
{isLoading ? (
|
||||
<div className="h-6 w-12 animate-pulse rounded-full bg-slate-200"></div>
|
||||
) : dropOffCount === 0 ? (
|
||||
<span>-</span>
|
||||
) : (
|
||||
dropOffCount
|
||||
displayCountValue
|
||||
)}
|
||||
</span>
|
||||
{!isLoading && (
|
||||
<span className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700">
|
||||
<button
|
||||
className="ml-1 flex items-center rounded-md bg-slate-800 px-2 py-1 text-xs text-slate-50 group-hover:bg-slate-700"
|
||||
onClick={() => setShowDropOffs(!showDropOffs)}>
|
||||
{showDropOffs ? (
|
||||
<ChevronUpIcon className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -135,6 +135,7 @@ export const SummaryMetadata = ({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.time_to_complete")}
|
||||
percentage={null}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { QuestionFilterComboBox } from "./QuestionFilterComboBox";
|
||||
|
||||
describe("QuestionFilterComboBox", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const defaultProps = {
|
||||
filterOptions: ["A", "B"],
|
||||
filterComboBoxOptions: ["X", "Y"],
|
||||
filterValue: undefined,
|
||||
filterComboBoxValue: undefined,
|
||||
onChangeFilterValue: vi.fn(),
|
||||
onChangeFilterComboBoxValue: vi.fn(),
|
||||
handleRemoveMultiSelect: vi.fn(),
|
||||
disabled: false,
|
||||
};
|
||||
|
||||
test("renders select placeholders", () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
expect(screen.getAllByText(/common.select\.../).length).toBe(2);
|
||||
});
|
||||
|
||||
test("calls onChangeFilterValue when selecting filter", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]);
|
||||
await userEvent.click(screen.getByText("A"));
|
||||
expect(defaultProps.onChangeFilterValue).toHaveBeenCalledWith("A");
|
||||
});
|
||||
|
||||
test("calls onChangeFilterComboBoxValue when selecting combo box option", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} filterValue="A" />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
await userEvent.click(screen.getByText("X"));
|
||||
expect(defaultProps.onChangeFilterComboBoxValue).toHaveBeenCalledWith("X");
|
||||
});
|
||||
|
||||
test("multi-select removal works", async () => {
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "multipleChoiceMulti",
|
||||
filterValue: "A",
|
||||
filterComboBoxValue: ["X", "Y"],
|
||||
};
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
const removeButtons = screen.getAllByRole("button", { name: /X/i });
|
||||
await userEvent.click(removeButtons[0]);
|
||||
expect(props.handleRemoveMultiSelect).toHaveBeenCalledWith(["Y"]);
|
||||
});
|
||||
|
||||
test("disabled state prevents opening", async () => {
|
||||
render(<QuestionFilterComboBox {...defaultProps} disabled />);
|
||||
await userEvent.click(screen.getAllByRole("button")[0]);
|
||||
expect(screen.queryByText("A")).toBeNull();
|
||||
});
|
||||
|
||||
test("handles object options correctly", async () => {
|
||||
const obj = { default: "Obj1", en: "ObjEN" };
|
||||
const props = {
|
||||
...defaultProps,
|
||||
type: "multipleChoiceMulti",
|
||||
filterValue: "A",
|
||||
filterComboBoxOptions: [obj],
|
||||
filterComboBoxValue: [],
|
||||
} as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
await userEvent.click(screen.getByText("Obj1"));
|
||||
expect(props.onChangeFilterComboBoxValue).toHaveBeenCalledWith(["Obj1"]);
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Submitted", async () => {
|
||||
const props = { ...defaultProps, type: "NPS", filterValue: "Submitted" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
});
|
||||
|
||||
test("prevent combo-box opening when filterValue is Skipped", async () => {
|
||||
const props = { ...defaultProps, type: "Rating", filterValue: "Skipped" } as any;
|
||||
render(<QuestionFilterComboBox {...props} />);
|
||||
await userEvent.click(screen.getAllByRole("button")[1]);
|
||||
expect(screen.queryByText("X")).toHaveClass("data-[disabled='true']:opacity-50");
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,39 @@ export const QuestionFilterComboBox = ({
|
||||
.includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
const filterComboBoxItem = !Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const commandItemOnSelect = (o: string) => {
|
||||
if (!isMultiple) {
|
||||
onChangeFilterComboBoxValue(typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o);
|
||||
} else {
|
||||
onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [...filterComboBoxValue, typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
}
|
||||
if (!isMultiple) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="inline-flex w-full flex-row">
|
||||
{filterOptions && filterOptions?.length <= 1 ? (
|
||||
@@ -130,39 +163,37 @@ export const QuestionFilterComboBox = ({
|
||||
)}
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent">
|
||||
<div
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
className={clsx(
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
"group flex items-center justify-between rounded-md rounded-l-none bg-white px-3 py-2 text-sm"
|
||||
)}>
|
||||
{filterComboBoxValue && filterComboBoxValue?.length > 0 ? (
|
||||
!Array.isArray(filterComboBoxValue) ? (
|
||||
<p className="text-slate-600">{filterComboBoxValue}</p>
|
||||
) : (
|
||||
<div className="no-scrollbar flex w-[7rem] gap-3 overflow-auto md:w-[10rem] lg:w-[18rem]">
|
||||
{typeof filterComboBoxValue !== "string" &&
|
||||
filterComboBoxValue?.map((o, index) => (
|
||||
<button
|
||||
key={`${o}-${index}`}
|
||||
type="button"
|
||||
onClick={() => handleRemoveMultiSelect(filterComboBoxValue.filter((i) => i !== o))}
|
||||
className="flex w-30 items-center bg-slate-100 px-2 whitespace-nowrap text-slate-600">
|
||||
{o}
|
||||
<X width={14} height={14} className="ml-2" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
{filterComboBoxValue && filterComboBoxValue.length > 0 ? (
|
||||
filterComboBoxItem
|
||||
) : (
|
||||
<p className="text-slate-400">{t("common.select")}...</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"flex-1 text-left text-slate-400",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{t("common.select")}...
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !disabled && !isDisabledComboBox && filterValue && setOpen(true)}
|
||||
disabled={disabled || isDisabledComboBox || !filterValue}
|
||||
className={clsx(
|
||||
"ml-2 flex items-center justify-center",
|
||||
disabled || isDisabledComboBox || !filterValue ? "opacity-50" : "cursor-pointer"
|
||||
)}>
|
||||
{open ? (
|
||||
<ChevronUp className="ml-2 h-4 w-4 opacity-50" />
|
||||
<ChevronUp className="h-4 w-4 opacity-50" />
|
||||
) : (
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
@@ -183,21 +214,7 @@ export const QuestionFilterComboBox = ({
|
||||
{filteredOptions?.map((o, index) => (
|
||||
<CommandItem
|
||||
key={`option-${typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}-${index}`}
|
||||
onSelect={() => {
|
||||
!isMultiple
|
||||
? onChangeFilterComboBoxValue(
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o
|
||||
)
|
||||
: onChangeFilterComboBoxValue(
|
||||
Array.isArray(filterComboBoxValue)
|
||||
? [
|
||||
...filterComboBoxValue,
|
||||
typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o,
|
||||
]
|
||||
: [typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o]
|
||||
);
|
||||
!isMultiple && setOpen(false);
|
||||
}}
|
||||
onSelect={() => commandItemOnSelect(o)}
|
||||
className="cursor-pointer">
|
||||
{typeof o === "object" ? getLocalizedValue(o, defaultLanguageCode) : o}
|
||||
</CommandItem>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { OptionsType, QuestionOption, QuestionOptions, QuestionsComboBox } from "./QuestionsComboBox";
|
||||
|
||||
describe("QuestionsComboBox", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
const mockOptions: QuestionOptions[] = [
|
||||
{
|
||||
header: OptionsType.QUESTIONS,
|
||||
option: [{ label: "Q1", type: OptionsType.QUESTIONS, questionType: undefined, id: "1" }],
|
||||
},
|
||||
{
|
||||
header: OptionsType.TAGS,
|
||||
option: [{ label: "Tag1", type: OptionsType.TAGS, id: "t1" }],
|
||||
},
|
||||
];
|
||||
|
||||
test("renders selected label when closed", () => {
|
||||
const selected: Partial<QuestionOption> = { label: "Q1", type: OptionsType.QUESTIONS, id: "1" };
|
||||
render(<QuestionsComboBox options={mockOptions} selected={selected} onChangeValue={() => {}} />);
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("opens dropdown, selects an option, and closes", async () => {
|
||||
let currentSelected: Partial<QuestionOption> = {};
|
||||
const onChange = vi.fn((option) => {
|
||||
currentSelected = option;
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />
|
||||
);
|
||||
|
||||
// Open the dropdown
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
expect(screen.getByPlaceholderText("common.search...")).toBeInTheDocument();
|
||||
|
||||
// Select an option
|
||||
await userEvent.click(screen.getByText("Q1"));
|
||||
|
||||
// Check if onChange was called
|
||||
expect(onChange).toHaveBeenCalledWith(mockOptions[0].option[0]);
|
||||
|
||||
// Rerender with the new selected value
|
||||
rerender(<QuestionsComboBox options={mockOptions} selected={currentSelected} onChangeValue={onChange} />);
|
||||
|
||||
// Check if the input is gone and the selected item is displayed
|
||||
expect(screen.queryByPlaceholderText("common.search...")).toBeNull();
|
||||
expect(screen.getByText("Q1")).toBeInTheDocument(); // Verify the selected item is now displayed
|
||||
});
|
||||
});
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
StarIcon,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useRef, useState } from "react";
|
||||
import { TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
|
||||
|
||||
export enum OptionsType {
|
||||
@@ -141,15 +141,15 @@ const SelectedCommandItem = ({ label, questionType, type }: Partial<QuestionOpti
|
||||
};
|
||||
|
||||
export const QuestionsComboBox = ({ options, selected, onChangeValue }: QuestionComboBoxProps) => {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const { t } = useTranslate();
|
||||
const commandRef = React.useRef(null);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const commandRef = useRef(null);
|
||||
const [inputValue, setInputValue] = useState("");
|
||||
useClickOutside(commandRef, () => setOpen(false));
|
||||
|
||||
return (
|
||||
<Command ref={commandRef} className="h-10 overflow-visible bg-transparent hover:bg-slate-50">
|
||||
<div
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="group flex cursor-pointer items-center justify-between rounded-md bg-white px-3 py-2 text-sm">
|
||||
{!open && selected.hasOwnProperty("label") && (
|
||||
@@ -174,14 +174,14 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
<ChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div className="relative mt-2 h-full">
|
||||
{open && (
|
||||
<div className="animate-in bg-popover absolute top-0 z-50 max-h-52 w-full overflow-auto rounded-md bg-white outline-none">
|
||||
<CommandList>
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
<>
|
||||
<Fragment key={data.header}>
|
||||
{data?.option.length > 0 && (
|
||||
<CommandGroup
|
||||
heading={<p className="text-sm font-normal text-slate-600">{data.header}</p>}>
|
||||
@@ -199,7 +199,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</>
|
||||
</Fragment>
|
||||
))}
|
||||
</CommandList>
|
||||
</div>
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import {
|
||||
DELETE,
|
||||
GET,
|
||||
PUT,
|
||||
} from "@/modules/api/v2/management/contact-attribute-keys/[contactAttributeKeyId]/route";
|
||||
|
||||
export { GET, PUT, DELETE };
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GET, POST } from "@/modules/api/v2/management/contact-attribute-keys/route";
|
||||
|
||||
export { GET, POST };
|
||||
@@ -7,7 +7,6 @@ import { generateSurveySingleUseId, validateSurveySingleUseId } from "./singleUs
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
symmetricEncrypt: vi.fn(),
|
||||
symmetricDecrypt: vi.fn(),
|
||||
decryptAES128: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock constants
|
||||
|
||||
59
apps/web/lib/crypto.test.ts
Normal file
59
apps/web/lib/crypto.test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
143
apps/web/lib/language/tests/language.test.ts
Normal file
143
apps/web/lib/language/tests/language.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
176
apps/web/lib/utils/billing.test.ts
Normal file
176
apps/web/lib/utils/billing.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
54
apps/web/lib/utils/billing.ts
Normal file
54
apps/web/lib/utils/billing.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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("*");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" }],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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>;
|
||||
@@ -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 }],
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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 || {};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -72,6 +72,6 @@ export const POST = async (request: NextRequest) =>
|
||||
return handleApiError(request, createWebhookResult.error);
|
||||
}
|
||||
|
||||
return responses.successResponse(createWebhookResult);
|
||||
return responses.createdResponse(createWebhookResult);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", "");
|
||||
|
||||
|
||||
152
apps/web/modules/survey/editor/lib/utils.test.tsx
Normal file
152
apps/web/modules/survey/editor/lib/utils.test.tsx
Normal 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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
105
apps/web/modules/ui/components/progress-bar/index.test.tsx
Normal file
105
apps/web/modules/ui/components/progress-bar/index.test.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { HalfCircle, ProgressBar } from ".";
|
||||
|
||||
describe("ProgressBar", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders with default height and correct progress", () => {
|
||||
const { container } = render(<ProgressBar progress={0.5} barColor="bg-blue-500" />);
|
||||
const outerDiv = container.firstChild as HTMLElement;
|
||||
const innerDiv = outerDiv.firstChild as HTMLElement;
|
||||
|
||||
expect(outerDiv).toHaveClass("h-5"); // Default height
|
||||
expect(outerDiv).toHaveClass("w-full rounded-full bg-slate-200");
|
||||
expect(innerDiv).toHaveClass("h-full rounded-full bg-blue-500");
|
||||
expect(innerDiv.style.width).toBe("50%");
|
||||
});
|
||||
|
||||
test("renders with specified height (h-2)", () => {
|
||||
const { container } = render(<ProgressBar progress={0.75} barColor="bg-green-500" height={2} />);
|
||||
const outerDiv = container.firstChild as HTMLElement;
|
||||
const innerDiv = outerDiv.firstChild as HTMLElement;
|
||||
|
||||
expect(outerDiv).toHaveClass("h-2"); // Specified height
|
||||
expect(innerDiv).toHaveClass("bg-green-500");
|
||||
expect(innerDiv.style.width).toBe("75%");
|
||||
});
|
||||
|
||||
test("caps progress at 100%", () => {
|
||||
const { container } = render(<ProgressBar progress={1.2} barColor="bg-red-500" />);
|
||||
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerDiv.style.width).toBe("100%");
|
||||
});
|
||||
|
||||
test("handles progress less than 0%", () => {
|
||||
const { container } = render(<ProgressBar progress={-0.1} barColor="bg-yellow-500" />);
|
||||
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerDiv.style.width).toBe("0%");
|
||||
});
|
||||
|
||||
test("applies barColor class", () => {
|
||||
const testColor = "bg-purple-600";
|
||||
const { container } = render(<ProgressBar progress={0.3} barColor={testColor} />);
|
||||
const innerDiv = (container.firstChild as HTMLElement).firstChild as HTMLElement;
|
||||
expect(innerDiv).toHaveClass(testColor);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HalfCircle", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
test("renders correctly with a given value", () => {
|
||||
const testValue = 50;
|
||||
const { getByText, container } = render(<HalfCircle value={testValue} />);
|
||||
|
||||
// Check if boundary values and the main value are rendered
|
||||
expect(getByText("-100")).toBeInTheDocument();
|
||||
expect(getByText("100")).toBeInTheDocument();
|
||||
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
|
||||
|
||||
// Check rotation calculation: normalized = (50 + 100) / 200 = 0.75; mapped = (0.75 * 180 - 180) = -45deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv).toBeInTheDocument();
|
||||
expect(rotatingDiv.style.rotate).toBe("-45deg");
|
||||
});
|
||||
|
||||
test("renders correctly with value -100", () => {
|
||||
const testValue = -100;
|
||||
const { getAllByText, getByText, container } = render(<HalfCircle value={testValue} />);
|
||||
// Check boundary labels
|
||||
expect(getAllByText("-100")[0]).toBeInTheDocument();
|
||||
expect(getByText("100")).toBeInTheDocument();
|
||||
|
||||
// Check the main value using a more specific selector
|
||||
const mainValueElement = container.querySelector(".text-2xl.text-black");
|
||||
expect(mainValueElement).toBeInTheDocument();
|
||||
expect(mainValueElement?.textContent).toBe(Math.round(testValue).toString());
|
||||
|
||||
// normalized = (-100 + 100) / 200 = 0; mapped = (0 * 180 - 180) = -180deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv.style.rotate).toBe("-180deg");
|
||||
});
|
||||
|
||||
test("renders correctly with value 100", () => {
|
||||
const testValue = 100;
|
||||
const { getAllByText, container } = render(<HalfCircle value={testValue} />);
|
||||
expect(getAllByText(Math.round(testValue).toString())[0]).toBeInTheDocument();
|
||||
// normalized = (100 + 100) / 200 = 1; mapped = (1 * 180 - 180) = 0deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv.style.rotate).toBe("0deg");
|
||||
});
|
||||
|
||||
test("renders correctly with value 0", () => {
|
||||
const testValue = 0;
|
||||
const { getByText, container } = render(<HalfCircle value={testValue} />);
|
||||
expect(getByText(Math.round(testValue).toString())).toBeInTheDocument();
|
||||
// normalized = (0 + 100) / 200 = 0.5; mapped = (0.5 * 180 - 180) = -90deg
|
||||
const rotatingDiv = container.querySelector(".bg-brand-dark") as HTMLElement;
|
||||
expect(rotatingDiv.style.rotate).toBe("-90deg");
|
||||
});
|
||||
});
|
||||
@@ -9,11 +9,24 @@ interface ProgressBarProps {
|
||||
}
|
||||
|
||||
export const ProgressBar: React.FC<ProgressBarProps> = ({ progress, barColor, height = 5 }) => {
|
||||
const heightClass = () => {
|
||||
switch (height) {
|
||||
case 2:
|
||||
return "h-2";
|
||||
case 5:
|
||||
return "h-5";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const maxWidth = Math.floor(Math.max(0, Math.min(progress, 1)) * 100);
|
||||
|
||||
return (
|
||||
<div className={cn(height === 2 ? "h-2" : height === 5 ? "h-5" : "", "w-full rounded-full bg-slate-200")}>
|
||||
<div className={cn(heightClass(), "w-full rounded-full bg-slate-200")}>
|
||||
<div
|
||||
className={cn("h-full rounded-full", barColor)}
|
||||
style={{ width: `${Math.floor(progress * 100)}%`, transition: "width 0.5s ease-out" }}></div>
|
||||
style={{ width: `${maxWidth}%`, transition: "width 0.5s ease-out" }}></div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ const nextConfig = {
|
||||
serverExternalPackages: ["@aws-sdk", "@opentelemetry/instrumentation", "pino", "pino-pretty"],
|
||||
outputFileTracingIncludes: {
|
||||
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
|
||||
"/api/auth/**/*": ["../../node_modules/jose/**/*"],
|
||||
},
|
||||
i18n: {
|
||||
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],
|
||||
|
||||
@@ -35,6 +35,7 @@ export default defineConfig({
|
||||
"modules/organization/settings/teams/components/edit-memberships/organization-actions.tsx",
|
||||
"modules/ui/components/alert/*.tsx",
|
||||
"modules/ui/components/environmentId-base-layout/*.tsx",
|
||||
"modules/ui/components/progress-bar/index.tsx",
|
||||
"app/(app)/environments/**/layout.tsx",
|
||||
"app/(app)/environments/**/settings/(organization)/general/page.tsx",
|
||||
"app/(app)/environments/**/components/PosthogIdentify.tsx",
|
||||
@@ -47,6 +48,20 @@ export default defineConfig({
|
||||
"app/intercom/*.tsx",
|
||||
"app/sentry/*.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SurveyAnalysisCTA.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/ConsentSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MatrixQuestionSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/MultipleChoiceSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/NPSSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/PictureChoiceSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/RatingSummary.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/summary/components/SummaryMetadata.tsx",
|
||||
"app/(app)/environments/**/surveys/**/components/QuestionFilterComboBox.tsx",
|
||||
"app/(app)/environments/**/surveys/**/components/QuestionsComboBox.tsx",
|
||||
"app/(app)/environments/**/integrations/airtable/components/ManageIntegration.tsx",
|
||||
"app/(app)/environments/**/integrations/google-sheets/components/ManageIntegration.tsx",
|
||||
"apps/web/app/(app)/environments/**/integrations/notion/components/ManageIntegration.tsx",
|
||||
"app/(app)/environments/**/integrations/slack/components/ManageIntegration.tsx",
|
||||
"app/(app)/environments/**/surveys/**/(analysis)/responses/components/ResponseTableCell.tsx",
|
||||
"modules/ee/sso/lib/**/*.ts",
|
||||
"app/lib/**/*.ts",
|
||||
"app/api/(internal)/insights/lib/**/*.ts",
|
||||
@@ -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/**",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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}:*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -5,10 +5,7 @@
|
||||
-->
|
||||
<data-extraction-rules>
|
||||
<cloud-backup>
|
||||
<!-- TODO: Use <include> and <exclude> to control what is backed up.
|
||||
<include .../>
|
||||
<exclude .../>
|
||||
-->
|
||||
<!-- No specific include/exclude -->
|
||||
</cloud-backup>
|
||||
<!--
|
||||
<device-transfer>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">192.168.0.12</domain>
|
||||
<domain-config cleartextTrafficPermitted="false">
|
||||
<domain includeSubdomains="true">192.168.29.120</domain>
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -23,7 +23,7 @@ android {
|
||||
enableAndroidTestCoverage = true
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
|
||||
@@ -1,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 { *; }
|
||||
@@ -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 { *; }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.formbricks.formbrickssdk.model.enums
|
||||
|
||||
enum class SuccessType {
|
||||
SET_USER_SUCCESS,
|
||||
GET_ENVIRONMENT_SUCCESS,
|
||||
LOGOUT_SUCCESS
|
||||
}
|
||||
@@ -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>?
|
||||
)
|
||||
@@ -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
Reference in New Issue
Block a user