mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 11:20:56 -05:00
Compare commits
162 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e82e0c87a4 | |||
| f2f2defd10 | |||
| 128e94e4cf | |||
| ee0ea7caa6 | |||
| 52dc64ffd2 | |||
| bec3fa2dbd | |||
| 18a3c4f0f7 | |||
| 6c61afec2f | |||
| ce4d9350e2 | |||
| 3e6f81268d | |||
| 8137de3c80 | |||
| bf0ad45697 | |||
| b1a4277ca8 | |||
| 1876c13f52 | |||
| 0623bb9ff5 | |||
| d37cddaa7e | |||
| 24f632f9ce | |||
| b041e3da86 | |||
| 8d91a3db62 | |||
| c05e3f192d | |||
| 5b61e00560 | |||
| bf592937f4 | |||
| 7ed7101ac1 | |||
| 2e926936fb | |||
| a386451e6e | |||
| f0bf111e7b | |||
| 8a57a5b74b | |||
| 434cb1d0d0 | |||
| 8bde75a9ff | |||
| 6b880f29cb | |||
| 969c9834e5 | |||
| 5e33b7c9a4 | |||
| 230ea744fa | |||
| fae1fb8f96 | |||
| eac35daed9 | |||
| 45accc1edb | |||
| 02ebe8e9f8 | |||
| cae859e326 | |||
| 5352d563b6 | |||
| 711f2bfe67 | |||
| 6fcb5d39a2 | |||
| 1ed9859ee7 | |||
| cd72a0a78d | |||
| 2b09795787 | |||
| 2451acb9bd | |||
| 14dcded91b | |||
| 46062f91cd | |||
| ffd4478184 | |||
| 69da1862fa | |||
| c11d3241ab | |||
| 3fb09a1a26 | |||
| 6efa449c10 | |||
| 34b94689ca | |||
| 901fac7e08 | |||
| 739c662863 | |||
| 535974ff8a | |||
| a8b97abe9a | |||
| 28103604b4 | |||
| b5a7e15386 | |||
| fec4746d5d | |||
| 175323e7d9 | |||
| 6130737d51 | |||
| bf10a8d0b2 | |||
| 612f8dceb8 | |||
| 0303f16db4 | |||
| 07635b160e | |||
| 9cfcffdb5e | |||
| 02264ffc5f | |||
| e489c6a346 | |||
| 7dde3edd8d | |||
| 4304a7efd6 | |||
| a89d598f8d | |||
| 6ff5af712f | |||
| 398ba79e7e | |||
| 5127de9de0 | |||
| 2bf7788a1b | |||
| ee8122778b | |||
| 8aaa7ed9c0 | |||
| bc7c8c5715 | |||
| ab1ea7a5ce | |||
| 4f749355e0 | |||
| 18b60ddd35 | |||
| 87f1b01c7a | |||
| 851ea0deb2 | |||
| 9abbbfdd35 | |||
| cefc2bdf60 | |||
| 78473bf3d0 | |||
| 15403c6a92 | |||
| 35b98863a4 | |||
| 65f5968fb1 | |||
| 2dfea4d72f | |||
| 990c0eee31 | |||
| 07f16b8a43 | |||
| bf556b0608 | |||
| 8b0766a46e | |||
| 1f995d6e25 | |||
| 975a4d57f8 | |||
| 69bd576fc5 | |||
| a2e4a3bbd7 | |||
| 281f854332 | |||
| 24496774a5 | |||
| aeaf3215b4 | |||
| f4c5162590 | |||
| dedb7389f0 | |||
| ff77118932 | |||
| 7aed1b84de | |||
| 79a773432a | |||
| d53869f1df | |||
| fc9ddb2b0d | |||
| 6fcb6863bd | |||
| b1cee91ad9 | |||
| 9d2e988c59 | |||
| 60bd5cbeff | |||
| b6a3a15379 | |||
| c68f214eff | |||
| c90ee84483 | |||
| dc1ee72594 | |||
| 924132287e | |||
| e6f347aa07 | |||
| 367bc23dd4 | |||
| a1a11b2bb8 | |||
| 31d2ea7444 | |||
| 0653c6a59f | |||
| b6d793e109 | |||
| d361c334d3 | |||
| a4d808b479 | |||
| 18ae1748d3 | |||
| 3404e0c494 | |||
| 83499ae552 | |||
| 2ac0c1eb07 | |||
| 54ede3015e | |||
| 1b4f05a062 | |||
| 197dbf5aa6 | |||
| 7ca52a7a93 | |||
| 4a48839d17 | |||
| 92bd9bdac7 | |||
| ad4b6f8b8c | |||
| 8de5079db3 | |||
| a60206dd44 | |||
| d66abdcdaf | |||
| 03fa41a911 | |||
| cab438e474 | |||
| a6dfe78c81 | |||
| e4d96f4379 | |||
| 581a66b4a9 | |||
| 5cf0c15812 | |||
| ebaa2d363c | |||
| 597ea40b75 | |||
| 3c39dcc2de | |||
| e8df1dbb35 | |||
| 84987ce557 | |||
| 784ed855d7 | |||
| 5a17d4144d | |||
| 65c9db86c6 | |||
| bc94d34d1e | |||
| 22be60a0ba | |||
| a384963863 | |||
| c067ae73bb | |||
| dc78a30cbe | |||
| 9c9ae8a3a2 | |||
| 29a08151aa | |||
| f42a8822a9 |
@@ -0,0 +1 @@
|
||||
{"sessionId":"f77248e2-8840-41c6-968b-c3b7d8a9e913","pid":49125,"acquiredAt":1776168010367}
|
||||
@@ -32,6 +32,24 @@ CRON_SECRET=
|
||||
# Set the minimum log level(debug, info, warn, error, fatal)
|
||||
LOG_LEVEL=info
|
||||
|
||||
# BullMQ workers require REDIS_URL (for example `redis://localhost:6379`) to be set.
|
||||
# BullMQ worker startup is enabled by default outside tests. Set to 0 to disable.
|
||||
# BULLMQ_WORKER_ENABLED=1
|
||||
# Set to 1 on web/API pods that only enqueue jobs while a separate BullMQ worker deployment consumes them.
|
||||
# BULLMQ_EXTERNAL_WORKER_ENABLED=0
|
||||
|
||||
# Number of BullMQ worker instances started per Formbricks server process.
|
||||
# BULLMQ_WORKER_COUNT=1
|
||||
|
||||
# Number of concurrent jobs each BullMQ worker can process.
|
||||
# BULLMQ_WORKER_CONCURRENCY=1
|
||||
|
||||
# Survey publish/close scheduling is configured with public build-time env vars because the editor UI
|
||||
# also needs to render the selected execution time and timezone.
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_TIME_ZONE=Europe/Berlin
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_HOUR=0
|
||||
# NEXT_PUBLIC_SURVEY_SCHEDULING_LOCAL_MINUTE=0
|
||||
|
||||
##############
|
||||
# DATABASE #
|
||||
##############
|
||||
@@ -278,5 +296,24 @@ REDIS_URL=redis://localhost:6379
|
||||
# AUDIT_LOG_GET_USER_IP=0
|
||||
|
||||
|
||||
# Cube.js Analytics (optional — only needed for the analytics/dashboard feature)
|
||||
# Required when running the Cube service (docker-compose.dev.yml). Generate with: openssl rand -hex 32
|
||||
# Use the same value for CUBEJS_API_TOKEN so the client can authenticate.
|
||||
# CUBEJS_API_SECRET=
|
||||
# URL where the Cube.js instance is running
|
||||
# CUBEJS_API_URL=http://localhost:4000
|
||||
# API token sent with each Cube.js request; must match CUBEJS_API_SECRET when CUBEJS_DEV_MODE is off
|
||||
# CUBEJS_API_TOKEN=
|
||||
#
|
||||
# Cube connects to the local Postgres service by default in docker-compose.dev.yml.
|
||||
# Override these only if your Hub DB runs on a different host.
|
||||
# CUBEJS_DB_HOST=postgres
|
||||
# CUBEJS_DB_PORT=5432
|
||||
# CUBEJS_DB_NAME=postgres
|
||||
# CUBEJS_DB_USER=postgres
|
||||
# CUBEJS_DB_PASS=postgres
|
||||
#
|
||||
# Alternative (when not on same Docker network): host.docker.internal and port 5433
|
||||
|
||||
# Lingo.dev API key for translation generation
|
||||
LINGO_API_KEY=your_api_key_here
|
||||
|
||||
@@ -32,6 +32,7 @@ The `@formbricks/surveys` package is pre-compiled (Vite → UMD + ESM) and the b
|
||||
|
||||
TypeScript, React, and Prisma are the primary languages. Use the shared ESLint presets (`@formbricks/eslint-config`) and Prettier preset (110-char width, semicolons, double quotes, sorted import groups). Two-space indentation is standard; prefer `PascalCase` for React components and folders under `modules/`, `camelCase` for functions/variables, and `SCREAMING_SNAKE_CASE` only for constants. When adding mocks, place them inside `__mocks__` so import ordering stays stable.
|
||||
We are using SonarQube to identify code smells and security hotspots.
|
||||
Always mark React component props as `Readonly<>` (e.g., `({ children }: Readonly<MyProps>)`).
|
||||
|
||||
## Architecture & Patterns
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.2.17",
|
||||
"storybook": "10.2.17",
|
||||
"vite": "7.3.1",
|
||||
"vite": "7.3.2",
|
||||
"@storybook/addon-docs": "10.2.17"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
const Page = () => {
|
||||
return redirect("/");
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { ChartsListPage } from "@/modules/ee/analysis/charts/components/charts-list-page";
|
||||
|
||||
const ChartsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <ChartsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default ChartsPage;
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { DashboardDetailPage } from "@/modules/ee/analysis/dashboards/pages/dashboard-detail-page";
|
||||
|
||||
const Page = (props: { params: Promise<{ workspaceId: string; dashboardId: string }> }) => {
|
||||
return <DashboardDetailPage params={props.params} />;
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { DashboardsListPage } from "@/modules/ee/analysis/dashboards/pages/dashboards-list-page";
|
||||
|
||||
const DashboardsPage = async (props: Readonly<{ params: Promise<{ workspaceId: string }> }>) => {
|
||||
const { workspaceId } = await props.params;
|
||||
return <DashboardsListPage workspaceId={workspaceId} />;
|
||||
};
|
||||
|
||||
export default DashboardsPage;
|
||||
@@ -0,0 +1,3 @@
|
||||
import { AnalysisListLoading } from "@/modules/ee/analysis/loading";
|
||||
|
||||
export default AnalysisListLoading;
|
||||
@@ -0,0 +1 @@
|
||||
export { WorkspaceSourcesPage as default } from "@/modules/workspaces/settings/sources/page";
|
||||
@@ -23,9 +23,13 @@ import { createWorkspace } from "@/modules/workspaces/settings/lib/workspace";
|
||||
import { getOrganizationsByUserId } from "./lib/organization";
|
||||
import { getWorkspacesByUserId } from "./lib/workspace";
|
||||
|
||||
const ZCreateWorkspaceInput = ZWorkspaceUpdateInput.extend({
|
||||
feedbackRecordDirectoryId: ZId.optional(),
|
||||
});
|
||||
|
||||
const ZCreateWorkspaceAction = z.object({
|
||||
organizationId: ZId,
|
||||
data: ZWorkspaceUpdateInput,
|
||||
data: ZCreateWorkspaceInput,
|
||||
});
|
||||
|
||||
export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCreateWorkspaceAction).action(
|
||||
@@ -40,7 +44,7 @@ export const createWorkspaceAction = authenticatedActionClient.inputSchema(ZCrea
|
||||
access: [
|
||||
{
|
||||
data: parsedInput.data,
|
||||
schema: ZWorkspaceUpdateInput,
|
||||
schema: ZCreateWorkspaceInput,
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
ArrowUpRightIcon,
|
||||
BarChart3Icon,
|
||||
Building2Icon,
|
||||
ChevronRightIcon,
|
||||
Cog,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
Loader2,
|
||||
LogOutIcon,
|
||||
MessageCircle,
|
||||
MessageSquareTextIcon,
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
PlusIcon,
|
||||
@@ -144,44 +146,77 @@ export const MainNavigation = ({
|
||||
}
|
||||
}, [pathname]);
|
||||
|
||||
const mainNavigation = useMemo(
|
||||
const mainNavigationSections = useMemo(
|
||||
() => [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
id: "ask",
|
||||
name: "Ask",
|
||||
items: [
|
||||
{
|
||||
name: t("common.surveys"),
|
||||
href: `/workspaces/${workspace.id}/surveys`,
|
||||
icon: MessageCircle,
|
||||
isActive: pathname?.includes("/surveys"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
href: `/workspaces/${workspace.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
id: "unify-feedback",
|
||||
name: t("workspace.unify.unify_feedback"),
|
||||
items: [
|
||||
{
|
||||
name: t("workspace.unify.feedback_records"),
|
||||
href: `/workspaces/${workspace.id}/unify/feedback-records`,
|
||||
icon: MessageSquareTextIcon,
|
||||
isActive: pathname?.includes("/unify/feedback-records"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
{
|
||||
name: t("common.dashboards"),
|
||||
href: `/workspaces/${workspace.id}/dashboards`,
|
||||
icon: BarChart3Icon,
|
||||
isActive: pathname?.includes("/dashboards") || pathname?.includes("/charts"),
|
||||
isHidden: false,
|
||||
disabled: isMembershipPending || isBilling,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const configurationNavigationItem = useMemo(
|
||||
() => ({
|
||||
name: t("common.configuration"),
|
||||
href: `/workspaces/${workspace.id}/general`,
|
||||
icon: Cog,
|
||||
isActive:
|
||||
pathname?.includes("/general") ||
|
||||
pathname?.includes("/look") ||
|
||||
pathname?.includes("/app-connection") ||
|
||||
pathname?.includes("/feedback-sources") ||
|
||||
pathname?.includes("/integrations") ||
|
||||
pathname?.includes("/teams") ||
|
||||
pathname?.includes("/languages") ||
|
||||
pathname?.includes("/tags"),
|
||||
disabled: isMembershipPending || isBilling,
|
||||
}),
|
||||
[t, workspace.id, pathname, isMembershipPending, isBilling]
|
||||
);
|
||||
|
||||
const dropdownNavigation = [
|
||||
{
|
||||
label: t("common.account"),
|
||||
@@ -240,6 +275,11 @@ export const MainNavigation = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `/workspaces/${workspace.id}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `/workspaces/${workspace.id}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
@@ -297,6 +337,15 @@ export const MainNavigation = ({
|
||||
href: `/workspaces/${workspace.id}/settings/enterprise`,
|
||||
hidden: isFormbricksCloud || isMember,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `/workspaces/${workspace.id}/settings/feedback-record-directories`,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
];
|
||||
|
||||
const loadWorkspaces = useCallback(async () => {
|
||||
@@ -413,16 +462,22 @@ export const MainNavigation = ({
|
||||
: `/workspaces/${workspace.id}/surveys/`;
|
||||
|
||||
const handleWorkspaceChange = (workspaceId: string) => {
|
||||
if (workspaceId === workspace.id) return;
|
||||
const targetPath =
|
||||
workspaceId === workspace.id ? `/workspaces/${workspace.id}/surveys` : `/workspaces/${workspaceId}/`;
|
||||
startTransition(() => {
|
||||
router.push(`/workspaces/${workspaceId}/`);
|
||||
setIsWorkspaceDropdownOpen(false);
|
||||
router.push(targetPath);
|
||||
});
|
||||
};
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === organization.id) return;
|
||||
const targetPath =
|
||||
organizationId === organization.id
|
||||
? `/workspaces/${workspace.id}/settings/general`
|
||||
: `/organizations/${organizationId}/`;
|
||||
startTransition(() => {
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
setIsOrganizationDropdownOpen(false);
|
||||
router.push(targetPath);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -479,7 +534,7 @@ export const MainNavigation = ({
|
||||
);
|
||||
|
||||
const switcherIconClasses =
|
||||
"flex h-9 w-9 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-100 text-slate-600";
|
||||
const isInitialWorkspacesLoading =
|
||||
isWorkspaceDropdownOpen && !hasInitializedWorkspaces && !workspaceLoadError;
|
||||
|
||||
@@ -521,23 +576,50 @@ export const MainNavigation = ({
|
||||
</div>
|
||||
|
||||
{/* Main Nav Switch */}
|
||||
<ul>
|
||||
{mainNavigation.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
<ul className="space-y-2">
|
||||
{mainNavigationSections.map((section) => (
|
||||
<li key={section.id}>
|
||||
{!isCollapsed && !isTextVisible && (
|
||||
<p className="px-4 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-slate-400">
|
||||
{section.name}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ul>
|
||||
{section.items.map(
|
||||
(item) =>
|
||||
!item.isHidden && (
|
||||
<NavigationLink
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
isActive={item.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={item.disabled}
|
||||
disabledMessage={item.disabled ? disabledNavigationMessage : undefined}
|
||||
linkText={item.name}>
|
||||
<item.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
|
||||
<li className={cn("mt-2 border-t border-slate-100 pt-2", isCollapsed && "border-t-0 pt-0")}>
|
||||
<NavigationLink
|
||||
href={configurationNavigationItem.href}
|
||||
isActive={configurationNavigationItem.isActive}
|
||||
isCollapsed={isCollapsed}
|
||||
isTextVisible={isTextVisible}
|
||||
disabled={configurationNavigationItem.disabled}
|
||||
disabledMessage={
|
||||
configurationNavigationItem.disabled ? disabledNavigationMessage : undefined
|
||||
}
|
||||
linkText={configurationNavigationItem.name}>
|
||||
<configurationNavigationItem.icon strokeWidth={1.5} />
|
||||
</NavigationLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -117,8 +117,12 @@ export const OrganizationBreadcrumb = ({
|
||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
||||
|
||||
const handleOrganizationChange = (organizationId: string) => {
|
||||
if (organizationId === currentOrganizationId) return;
|
||||
startTransition(() => {
|
||||
setIsOrganizationDropdownOpen(false);
|
||||
if (organizationId === currentOrganizationId && currentWorkspaceId) {
|
||||
router.push(`/workspaces/${currentWorkspaceId}/settings/general`);
|
||||
return;
|
||||
}
|
||||
router.push(`/organizations/${organizationId}/`);
|
||||
});
|
||||
};
|
||||
@@ -144,12 +148,6 @@ export const OrganizationBreadcrumb = ({
|
||||
label: t("common.members_and_teams"),
|
||||
href: `${workspaceBasePath}/settings/teams`,
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.nav_label"),
|
||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
||||
hidden: isMember,
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
label: t("common.api_keys"),
|
||||
@@ -181,6 +179,15 @@ export const OrganizationBreadcrumb = ({
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
{
|
||||
id: "feedback-record-directories",
|
||||
label: t("workspace.settings.feedback_record_directories.title"),
|
||||
href: `${workspaceBasePath}/settings/feedback-record-directories`,
|
||||
disabled: isMembershipPending || isMember,
|
||||
disabledMessage: isMembershipPending
|
||||
? t("common.loading")
|
||||
: t("common.you_are_not_authorized_to_perform_this_action"),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -118,6 +118,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.website_and_app_connection"),
|
||||
href: `${workspaceBasePath}/app-connection`,
|
||||
},
|
||||
{
|
||||
id: "feedback-sources",
|
||||
label: t("workspace.unify.feedback_sources"),
|
||||
href: `${workspaceBasePath}/feedback-sources`,
|
||||
},
|
||||
{
|
||||
id: "integrations",
|
||||
label: t("common.integrations"),
|
||||
@@ -138,6 +143,11 @@ export const WorkspaceBreadcrumb = ({
|
||||
label: t("common.tags"),
|
||||
href: `${workspaceBasePath}/tags`,
|
||||
},
|
||||
{
|
||||
id: "unify",
|
||||
label: t("common.unify"),
|
||||
href: `${workspaceBasePath}/workspace/unify`,
|
||||
},
|
||||
];
|
||||
|
||||
const areWorkspaceSettingsDisabled = isMembershipPending || isBilling;
|
||||
@@ -153,9 +163,13 @@ export const WorkspaceBreadcrumb = ({
|
||||
}
|
||||
|
||||
const handleWorkspaceChange = (workspaceId: string) => {
|
||||
if (workspaceId === currentWorkspaceId) return;
|
||||
const targetPath =
|
||||
workspaceId === currentWorkspaceId
|
||||
? `/workspaces/${currentWorkspaceId}/surveys`
|
||||
: `/workspaces/${workspaceId}/`;
|
||||
startTransition(() => {
|
||||
router.push(`/workspaces/${workspaceId}/`);
|
||||
setIsWorkspaceDropdownOpen(false);
|
||||
router.push(targetPath);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
+11
-1
@@ -8,7 +8,7 @@ import type { TEnterpriseLicenseFeatures } from "@/modules/ee/license-check/type
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
|
||||
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled" | "ai">;
|
||||
type TPublicLicenseFeatureKey = Exclude<keyof TEnterpriseLicenseFeatures, "isMultiOrgEnabled">;
|
||||
|
||||
type TFeatureDefinition = {
|
||||
key: TPublicLicenseFeatureKey;
|
||||
@@ -61,6 +61,16 @@ const getFeatureDefinitions = (t: TFunction): TFeatureDefinition[] => {
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_spam_protection"),
|
||||
docsUrl: "https://formbricks.com/docs/xm-and-surveys/surveys/general-features/spam-protection",
|
||||
},
|
||||
{
|
||||
key: "aiSmartTools",
|
||||
labelKey: t("workspace.settings.general.ai_smart_tools_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "aiDataAnalysis",
|
||||
labelKey: t("workspace.settings.general.ai_data_analysis_enabled"),
|
||||
docsUrl: "https://formbricks.com/docs/self-hosting/configuration/ai",
|
||||
},
|
||||
{
|
||||
key: "auditLogs",
|
||||
labelKey: t("workspace.settings.enterprise.license_feature_audit_logs"),
|
||||
|
||||
+34
@@ -6,24 +6,32 @@ import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TOrganizationRole } from "@formbricks/types/memberships";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { useWorkspace } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { updateOrganizationAISettingsAction } from "@/app/(app)/workspaces/[workspaceId]/settings/(organization)/general/actions";
|
||||
import { getDisplayedOrganizationAISettingValue, getOrganizationAIEnablementState } from "@/lib/ai/utils";
|
||||
import { getAccessFlags } from "@/lib/membership/utils";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { type ModalButton, UpgradePrompt } from "@/modules/ui/components/upgrade-prompt";
|
||||
|
||||
interface AISettingsToggleProps {
|
||||
organization: TOrganization;
|
||||
membershipRole?: TOrganizationRole;
|
||||
isInstanceAIConfigured: boolean;
|
||||
hasAIPermission: boolean;
|
||||
isFormbricksCloud: boolean;
|
||||
}
|
||||
|
||||
export const AISettingsToggle = ({
|
||||
organization,
|
||||
membershipRole,
|
||||
isInstanceAIConfigured,
|
||||
hasAIPermission,
|
||||
isFormbricksCloud,
|
||||
}: Readonly<AISettingsToggleProps>) => {
|
||||
const { workspace } = useWorkspace();
|
||||
const workspaceBasePath = `/workspaces/${workspace?.id}`;
|
||||
const [loadingField, setLoadingField] = useState<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
@@ -78,6 +86,32 @@ export const AISettingsToggle = ({
|
||||
}
|
||||
};
|
||||
|
||||
const upgradeButtons: [ModalButton, ModalButton] = [
|
||||
{
|
||||
text: isFormbricksCloud ? t("common.upgrade_plan") : t("common.request_trial_license"),
|
||||
href: isFormbricksCloud
|
||||
? `${workspaceBasePath}/settings/billing`
|
||||
: "https://formbricks.com/upgrade-self-hosting-license",
|
||||
},
|
||||
{
|
||||
text: t("common.learn_more"),
|
||||
href: isFormbricksCloud
|
||||
? `${workspaceBasePath}/settings/billing`
|
||||
: "https://formbricks.com/learn-more-self-hosting-license",
|
||||
},
|
||||
];
|
||||
|
||||
if (!hasAIPermission) {
|
||||
return (
|
||||
<UpgradePrompt
|
||||
title={t("workspace.settings.general.unlock_ai_features_with_a_higher_plan")}
|
||||
description={t("workspace.settings.general.unlock_ai_features_description")}
|
||||
buttons={upgradeButtons}
|
||||
feature="ai_features"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showInstanceConfigWarning && (
|
||||
|
||||
+16
-3
@@ -3,7 +3,12 @@ import { isInstanceAIConfigured } from "@/lib/ai/service";
|
||||
import { FB_LOGO_URL, IS_FORMBRICKS_CLOUD, IS_STORAGE_CONFIGURED } from "@/lib/constants";
|
||||
import { getUser } from "@/lib/user/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getIsMultiOrgEnabled, getWhiteLabelPermission } from "@/modules/ee/license-check/lib/utils";
|
||||
import {
|
||||
getIsAIDataAnalysisEnabled,
|
||||
getIsAISmartToolsEnabled,
|
||||
getIsMultiOrgEnabled,
|
||||
getWhiteLabelPermission,
|
||||
} from "@/modules/ee/license-check/lib/utils";
|
||||
import { EmailCustomizationSettings } from "@/modules/ee/whitelabel/email-customization/components/email-customization-settings";
|
||||
import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
@@ -27,8 +32,14 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
|
||||
const user = session?.user?.id ? await getUser(session.user.id) : null;
|
||||
|
||||
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
|
||||
const hasWhiteLabelPermission = await getWhiteLabelPermission(organization.id);
|
||||
const [isMultiOrgEnabled, hasWhiteLabelPermission, hasAISmartToolsPermission, hasAIDataAnalysisPermission] =
|
||||
await Promise.all([
|
||||
getIsMultiOrgEnabled(),
|
||||
getWhiteLabelPermission(organization.id),
|
||||
getIsAISmartToolsEnabled(organization.id),
|
||||
getIsAIDataAnalysisEnabled(organization.id),
|
||||
]);
|
||||
const hasAIPermission = hasAISmartToolsPermission || hasAIDataAnalysisPermission;
|
||||
|
||||
const isDeleteDisabled = !isOwner || !isMultiOrgEnabled;
|
||||
const currentUserRole = currentUserMembership?.role;
|
||||
@@ -64,6 +75,8 @@ const Page = async (props: { params: Promise<{ workspaceId: string }> }) => {
|
||||
organization={organization}
|
||||
membershipRole={currentUserMembership?.role}
|
||||
isInstanceAIConfigured={isInstanceAIConfigured()}
|
||||
hasAIPermission={hasAIPermission}
|
||||
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
</SettingsCard>
|
||||
<EmailCustomizationSettings
|
||||
|
||||
@@ -21,6 +21,7 @@ export const SettingsCard = ({
|
||||
beta,
|
||||
className,
|
||||
buttonInfo,
|
||||
cta,
|
||||
}: {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -30,6 +31,7 @@ export const SettingsCard = ({
|
||||
beta?: boolean;
|
||||
className?: string;
|
||||
buttonInfo?: ButtonInfo;
|
||||
cta?: React.ReactNode;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
@@ -52,11 +54,12 @@ export const SettingsCard = ({
|
||||
{description}
|
||||
</Small>
|
||||
</div>
|
||||
{buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
)}
|
||||
{cta ??
|
||||
(buttonInfo && (
|
||||
<Button type="button" onClick={buttonInfo?.onClick} variant={buttonInfo?.variant ?? "default"}>
|
||||
{buttonInfo?.text}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className={cn(noPadding ? "" : "px-4 pt-4")}>{children}</div>
|
||||
</div>
|
||||
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryCes } from "@formbricks/types/surveys/types";
|
||||
import { RatingLikeSummary } from "./RatingLikeSummary";
|
||||
|
||||
interface CESSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryCes;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const CESSummary = ({ elementSummary, survey, setFilter }: CESSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
}, [elementSummary.element.scale]);
|
||||
|
||||
return (
|
||||
<RatingLikeSummary
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.effort_score")}: {elementSummary.average.toFixed(2)} /{" "}
|
||||
{elementSummary.element.range}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+72
@@ -0,0 +1,72 @@
|
||||
"use client";
|
||||
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryCsat } from "@formbricks/types/surveys/types";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { RatingLikeSummary } from "./RatingLikeSummary";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
|
||||
interface CSATSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryCsat;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
}
|
||||
|
||||
export const CSATSummary = ({ elementSummary, survey, setFilter }: CSATSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
}, [elementSummary.element.scale]);
|
||||
|
||||
return (
|
||||
<RatingLikeSummary
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
{t("workspace.surveys.summary.csat_satisfied", {
|
||||
percentage: elementSummary.csat.satisfiedPercentage,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("workspace.surveys.summary.csat_satisfied_tooltip", {
|
||||
percentage: elementSummary.csat.satisfiedPercentage,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+19
-8
@@ -8,7 +8,7 @@ import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryNps } from "@formbricks/types/surveys/types";
|
||||
import { HalfCircle, ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { convertFloatToNDecimal } from "../lib/utils";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
@@ -39,6 +39,7 @@ const calculateNPSOpacity = (rating: number): number => {
|
||||
export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
const promotersPercentage = convertFloatToNDecimal(elementSummary.promoters.percentage, 2);
|
||||
|
||||
const applyFilter = (group: string) => {
|
||||
const filters = {
|
||||
@@ -81,13 +82,23 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("workspace.surveys.summary.promoters")}:{" "}
|
||||
{convertFloatToNDecimal(elementSummary.promoters.percentage, 2)}%
|
||||
</div>
|
||||
</div>
|
||||
<TooltipProvider delayDuration={150}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.promoters.percentage} />
|
||||
<div>
|
||||
{t("workspace.surveys.summary.promoters")}: {promotersPercentage}%
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t("workspace.surveys.summary.nps_promoters_tooltip", {
|
||||
percentage: promotersPercentage,
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal } from "lucide-react";
|
||||
import { type JSX, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyElementSummaryCes,
|
||||
TSurveyElementSummaryCsat,
|
||||
TSurveyElementSummaryRating,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
|
||||
type RatingLikeElementSummary =
|
||||
| TSurveyElementSummaryCes
|
||||
| TSurveyElementSummaryCsat
|
||||
| TSurveyElementSummaryRating;
|
||||
|
||||
interface RatingLikeSummaryProps {
|
||||
elementSummary: RatingLikeElementSummary;
|
||||
survey: TSurvey;
|
||||
setFilter: (
|
||||
elementId: string,
|
||||
label: TI18nString,
|
||||
elementType: TSurveyElementTypeEnum,
|
||||
filterValue: string,
|
||||
filterComboBoxValue?: string | string[]
|
||||
) => void;
|
||||
additionalInfo: JSX.Element;
|
||||
}
|
||||
|
||||
export const RatingLikeSummary = ({
|
||||
elementSummary,
|
||||
survey,
|
||||
setFilter,
|
||||
additionalInfo,
|
||||
}: RatingLikeSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<ElementSummaryHeader elementSummary={elementSummary} survey={survey} additionalInfo={additionalInfo} />
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("workspace.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("workspace.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
{elementSummary.responseCount === 0 ? (
|
||||
<>
|
||||
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
|
||||
<RatingScaleLegend
|
||||
scale={elementSummary.element.scale}
|
||||
range={elementSummary.element.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{elementSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
const range = elementSummary.element.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.7;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === elementSummary.choices.length - 1;
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={result.rating}
|
||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
|
||||
}}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
t("workspace.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
||||
{elementSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.rating}
|
||||
className="flex flex-col items-center justify-center py-2"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight:
|
||||
index < elementSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}>
|
||||
<div className="mb-1 flex items-center justify-center">
|
||||
<RatingResponse
|
||||
scale={elementSummary.element.scale}
|
||||
answer={result.rating}
|
||||
range={elementSummary.element.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RatingScaleLegend
|
||||
scale={elementSummary.element.scale}
|
||||
range={elementSummary.element.range}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{elementSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
t("workspace.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={elementSummary.element.scale}
|
||||
answer={result.rating}
|
||||
range={elementSummary.element.range}
|
||||
addColors={elementSummary.element.isColorCodingEnabled}
|
||||
variant="individual"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: result.count })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
|
||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||
<div key="dismissed">
|
||||
<div className="text flex justify-between px-2">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+16
-192
@@ -1,21 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { BarChart, BarChartHorizontal, CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { CircleSlash2, SmileIcon, StarIcon } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type TI18nString } from "@formbricks/types/i18n";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey, TSurveyElementSummaryRating } from "@formbricks/types/surveys/types";
|
||||
import { convertFloatToNDecimal } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { ProgressBar } from "@/modules/ui/components/progress-bar";
|
||||
import { RatingResponse } from "@/modules/ui/components/rating-response";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
|
||||
import { TooltipProvider } from "@/modules/ui/components/tooltip";
|
||||
import { ClickableBarSegment } from "./ClickableBarSegment";
|
||||
import { ElementSummaryHeader } from "./ElementSummaryHeader";
|
||||
import { RatingScaleLegend } from "./RatingScaleLegend";
|
||||
import { SatisfactionIndicator } from "./SatisfactionIndicator";
|
||||
import { RatingLikeSummary } from "./RatingLikeSummary";
|
||||
|
||||
interface RatingSummaryProps {
|
||||
elementSummary: TSurveyElementSummaryRating;
|
||||
@@ -31,196 +22,29 @@ interface RatingSummaryProps {
|
||||
|
||||
export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSummaryProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState<"aggregated" | "individual">("aggregated");
|
||||
|
||||
const getIconBasedOnScale = useMemo(() => {
|
||||
const scale = elementSummary.element.scale;
|
||||
if (scale === "number") return <CircleSlash2 className="h-4 w-4" />;
|
||||
else if (scale === "star") return <StarIcon fill="rgb(250 204 21)" className="h-4 w-4 text-yellow-400" />;
|
||||
else if (scale === "smiley") return <SmileIcon className="h-4 w-4" />;
|
||||
}, [elementSummary]);
|
||||
}, [elementSummary.element.scale]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<ElementSummaryHeader
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
<SatisfactionIndicator percentage={elementSummary.csat.satisfiedPercentage} />
|
||||
<div>
|
||||
CSAT: {elementSummary.csat.satisfiedPercentage}% {t("workspace.surveys.summary.satisfied")}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as "aggregated" | "individual")}>
|
||||
<div className="flex justify-end px-4 md:px-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="aggregated" icon={<BarChartHorizontal className="h-4 w-4" />}>
|
||||
{t("workspace.surveys.summary.aggregated")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="individual" icon={<BarChart className="h-4 w-4" />}>
|
||||
{t("workspace.surveys.summary.individual")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="aggregated" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
{elementSummary.responseCount === 0 ? (
|
||||
<>
|
||||
<EmptyState text={t("workspace.surveys.summary.no_responses_found")} variant="simple" />
|
||||
<RatingScaleLegend
|
||||
scale={elementSummary.element.scale}
|
||||
range={elementSummary.element.range}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-t-lg border border-slate-200">
|
||||
{elementSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
const range = elementSummary.element.range;
|
||||
const opacity = 0.3 + (result.rating / range) * 0.8;
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === elementSummary.choices.length - 1;
|
||||
|
||||
return (
|
||||
<ClickableBarSegment
|
||||
key={result.rating}
|
||||
className="relative h-full cursor-pointer transition-opacity hover:brightness-110"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight: isLast ? "none" : "1px solid rgb(226, 232, 240)",
|
||||
}}
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
t("workspace.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
<div className="flex w-full overflow-hidden rounded-b-lg border border-t-0 border-slate-200 bg-slate-50">
|
||||
{elementSummary.choices.map((result, index) => {
|
||||
if (result.percentage === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.rating}
|
||||
className="flex flex-col items-center justify-center py-2"
|
||||
style={{
|
||||
width: `${result.percentage}%`,
|
||||
borderRight:
|
||||
index < elementSummary.choices.length - 1
|
||||
? "1px solid rgb(226, 232, 240)"
|
||||
: "none",
|
||||
}}>
|
||||
<div className="mb-1 flex items-center justify-center">
|
||||
<RatingResponse
|
||||
scale={elementSummary.element.scale}
|
||||
answer={result.rating}
|
||||
range={elementSummary.element.range}
|
||||
addColors={false}
|
||||
variant="aggregated"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs font-medium text-slate-600">
|
||||
{convertFloatToNDecimal(result.percentage, 1)}%
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<RatingScaleLegend
|
||||
scale={elementSummary.element.scale}
|
||||
range={elementSummary.element.range}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="individual" className="mt-4">
|
||||
<div className="px-4 pb-6 pt-4 md:px-6">
|
||||
<div className="space-y-5 text-sm md:text-base">
|
||||
{elementSummary.choices.map((result) => (
|
||||
<div key={result.rating}>
|
||||
<button
|
||||
className="w-full cursor-pointer hover:opacity-80"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
elementSummary.element.headline,
|
||||
elementSummary.element.type,
|
||||
t("workspace.surveys.summary.is_equal_to"),
|
||||
result.rating.toString()
|
||||
)
|
||||
}>
|
||||
<div className="text flex justify-between px-2 pb-2">
|
||||
<div className="mr-8 flex items-center space-x-1">
|
||||
<div className="font-semibold text-slate-700">
|
||||
<RatingResponse
|
||||
scale={elementSummary.element.scale}
|
||||
answer={result.rating}
|
||||
range={elementSummary.element.range}
|
||||
addColors={elementSummary.element.isColorCodingEnabled}
|
||||
variant="individual"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: result.count })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
{elementSummary.dismissed && elementSummary.dismissed.count > 0 && (
|
||||
<div className="rounded-b-lg border-t bg-white px-6 py-4">
|
||||
<div key="dismissed">
|
||||
<div className="text flex justify-between px-2">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||
</p>
|
||||
<RatingLikeSummary
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
additionalInfo={
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center space-x-2 rounded-lg bg-slate-100 p-2">
|
||||
{getIconBasedOnScale}
|
||||
<div>
|
||||
{t("workspace.surveys.summary.overall")}: {elementSummary.average.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+20
-5
@@ -22,8 +22,23 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
|
||||
const appSetupCompleted = workspace.appSetupCompleted;
|
||||
|
||||
useEffect(() => {
|
||||
const newSurveyParam = searchParams?.get("success");
|
||||
if (newSurveyParam && survey && workspace) {
|
||||
const publishSuccessParam = searchParams.get("success");
|
||||
const scheduledSuccessParam = searchParams.get("scheduled");
|
||||
|
||||
if (scheduledSuccessParam) {
|
||||
toast.success(t("workspace.surveys.summary.survey_scheduled_successfully"), {
|
||||
id: "survey-schedule-success-toast",
|
||||
duration: 5000,
|
||||
position: "bottom-right",
|
||||
});
|
||||
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete("scheduled");
|
||||
globalThis.history.replaceState({}, "", url.toString());
|
||||
return;
|
||||
}
|
||||
|
||||
if (publishSuccessParam) {
|
||||
setConfetti(true);
|
||||
toast.success(
|
||||
isAppSurvey && !appSetupCompleted
|
||||
@@ -38,16 +53,16 @@ export const SuccessMessage = ({ survey }: SummaryMetadataProps) => {
|
||||
);
|
||||
|
||||
// Remove success param from url
|
||||
const url = new URL(window.location.href);
|
||||
const url = new URL(globalThis.location.href);
|
||||
url.searchParams.delete("success");
|
||||
if (survey.type === "link") {
|
||||
// Add share param to url to open share embed modal
|
||||
url.searchParams.set("share", "true");
|
||||
}
|
||||
|
||||
window.history.replaceState({}, "", url.toString());
|
||||
globalThis.history.replaceState({}, "", url.toString());
|
||||
}
|
||||
}, [workspace, isAppSurvey, searchParams, survey, appSetupCompleted, t]);
|
||||
}, [appSetupCompleted, isAppSurvey, searchParams, survey.type, t]);
|
||||
|
||||
return <>{confetti && <Confetti />}</>;
|
||||
};
|
||||
|
||||
+22
@@ -13,6 +13,8 @@ import {
|
||||
SelectedFilterValue,
|
||||
useResponseFilter,
|
||||
} from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import { CESSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CESSummary";
|
||||
import { CSATSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CSATSummary";
|
||||
import { CTASummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CTASummary";
|
||||
import { CalSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/CalSummary";
|
||||
import { ConsentSummary } from "@/app/(app)/workspaces/[workspaceId]/surveys/[surveyId]/(analysis)/summary/components/ConsentSummary";
|
||||
@@ -156,6 +158,26 @@ export const SummaryList = ({ summary, responseCount, survey, locale }: SummaryL
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.CSAT) {
|
||||
return (
|
||||
<CSATSummary
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.CES) {
|
||||
return (
|
||||
<CESSummary
|
||||
key={elementSummary.element.id}
|
||||
elementSummary={elementSummary}
|
||||
survey={survey}
|
||||
setFilter={setFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (elementSummary.type === TSurveyElementTypeEnum.Consent) {
|
||||
return (
|
||||
<ConsentSummary
|
||||
|
||||
+608
@@ -4578,3 +4578,611 @@ describe("Cal question type tests", () => {
|
||||
expect(summary[0].skipped.count).toBe(1); // Counted as skipped
|
||||
});
|
||||
});
|
||||
|
||||
describe("CSAT question type tests", () => {
|
||||
test("getElementSummary correctly processes CSAT question with valid responses", async () => {
|
||||
const question = {
|
||||
id: "csat-q1",
|
||||
type: TSurveyElementTypeEnum.CSAT,
|
||||
headline: { default: "How satisfied are you?" },
|
||||
required: true,
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Very unsatisfied" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "response-1",
|
||||
data: { "csat-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-2",
|
||||
data: { "csat-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-3",
|
||||
data: { "csat-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-4",
|
||||
data: { "csat-q1": 1 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "csat-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
|
||||
expect(summary[0].responseCount).toBe(4);
|
||||
|
||||
// Average = (5 + 4 + 2 + 1) / 4 = 3.0
|
||||
expect(summary[0].average).toBe(3);
|
||||
|
||||
// CSAT: satisfied = ratings 4 + 5 = 2 out of 4
|
||||
expect(summary[0].csat.satisfiedCount).toBe(2);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(50);
|
||||
|
||||
// Verify choice distribution
|
||||
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
|
||||
expect(rating5.count).toBe(1);
|
||||
expect(rating5.percentage).toBe(25);
|
||||
|
||||
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
|
||||
expect(rating4.count).toBe(1);
|
||||
expect(rating4.percentage).toBe(25);
|
||||
|
||||
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
|
||||
expect(rating2.count).toBe(1);
|
||||
expect(rating2.percentage).toBe(25);
|
||||
|
||||
const rating1 = summary[0].choices.find((c: any) => c.rating === 1);
|
||||
expect(rating1.count).toBe(1);
|
||||
expect(rating1.percentage).toBe(25);
|
||||
|
||||
expect(summary[0].dismissed.count).toBe(0);
|
||||
});
|
||||
|
||||
test("getElementSummary handles CSAT question with dismissed responses", async () => {
|
||||
const question = {
|
||||
id: "csat-q1",
|
||||
type: TSurveyElementTypeEnum.CSAT,
|
||||
headline: { default: "How satisfied are you?" },
|
||||
required: false,
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Very unsatisfied" },
|
||||
upperLabel: { default: "Very satisfied" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "response-1",
|
||||
data: { "csat-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: { "csat-q1": 3 },
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-2",
|
||||
data: {},
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: { "csat-q1": 2 },
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-3",
|
||||
data: {},
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: { "csat-q1": 4 },
|
||||
finished: true,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "csat-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
expect(summary[0].average).toBe(5);
|
||||
expect(summary[0].dismissed.count).toBe(2);
|
||||
expect(summary[0].csat.satisfiedCount).toBe(1);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(100);
|
||||
});
|
||||
|
||||
test("getElementSummary handles CSAT question with no valid responses", async () => {
|
||||
const question = {
|
||||
id: "csat-q1",
|
||||
type: TSurveyElementTypeEnum.CSAT,
|
||||
headline: { default: "How satisfied are you?" },
|
||||
required: true,
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "response-1",
|
||||
data: { "other-q": "value" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "csat-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CSAT);
|
||||
expect(summary[0].responseCount).toBe(0);
|
||||
expect(summary[0].average).toBe(0);
|
||||
expect(summary[0].csat.satisfiedCount).toBe(0);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(0);
|
||||
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
|
||||
});
|
||||
|
||||
test("getElementSummary CSAT correctly identifies satisfied ratings (4 and 5 only)", async () => {
|
||||
const question = {
|
||||
id: "csat-q1",
|
||||
type: TSurveyElementTypeEnum.CSAT,
|
||||
headline: { default: "How satisfied are you?" },
|
||||
required: true,
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
// 3 satisfied (4,5,5), 2 not satisfied (1,3)
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "csat-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "csat-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "csat-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r4",
|
||||
data: { "csat-q1": 1 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r5",
|
||||
data: { "csat-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "csat-q1", impressions: 5, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
// satisfied = ratings 4 and 5 = 3 out of 5
|
||||
expect(summary[0].csat.satisfiedCount).toBe(3);
|
||||
expect(summary[0].csat.satisfiedPercentage).toBe(60);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CES question type tests", () => {
|
||||
test("getElementSummary correctly processes CES question with valid responses (range 5)", async () => {
|
||||
const question = {
|
||||
id: "ces-q1",
|
||||
type: TSurveyElementTypeEnum.CES,
|
||||
headline: { default: "How easy was it?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Very difficult" },
|
||||
upperLabel: { default: "Very easy" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "response-1",
|
||||
data: { "ces-q1": 5 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-2",
|
||||
data: { "ces-q1": 4 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-3",
|
||||
data: { "ces-q1": 2 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-4",
|
||||
data: { "ces-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "ces-q1", impressions: 4, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
|
||||
expect(summary[0].responseCount).toBe(4);
|
||||
|
||||
// CES = average = (5 + 4 + 2 + 3) / 4 = 3.5
|
||||
expect(summary[0].average).toBe(3.5);
|
||||
|
||||
// Verify choice distribution
|
||||
const rating5 = summary[0].choices.find((c: any) => c.rating === 5);
|
||||
expect(rating5.count).toBe(1);
|
||||
expect(rating5.percentage).toBe(25);
|
||||
|
||||
const rating4 = summary[0].choices.find((c: any) => c.rating === 4);
|
||||
expect(rating4.count).toBe(1);
|
||||
expect(rating4.percentage).toBe(25);
|
||||
|
||||
const rating3 = summary[0].choices.find((c: any) => c.rating === 3);
|
||||
expect(rating3.count).toBe(1);
|
||||
expect(rating3.percentage).toBe(25);
|
||||
|
||||
const rating2 = summary[0].choices.find((c: any) => c.rating === 2);
|
||||
expect(rating2.count).toBe(1);
|
||||
expect(rating2.percentage).toBe(25);
|
||||
|
||||
expect(summary[0].dismissed.count).toBe(0);
|
||||
|
||||
// CES has no csat field
|
||||
expect(summary[0].csat).toBeUndefined();
|
||||
});
|
||||
|
||||
test("getElementSummary correctly processes CES question with range 7", async () => {
|
||||
const question = {
|
||||
id: "ces-q1",
|
||||
type: TSurveyElementTypeEnum.CES,
|
||||
headline: { default: "How easy was it?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 7,
|
||||
lowerLabel: { default: "Very difficult" },
|
||||
upperLabel: { default: "Very easy" },
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "r1",
|
||||
data: { "ces-q1": 7 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r2",
|
||||
data: { "ces-q1": 6 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "r3",
|
||||
data: { "ces-q1": 1 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "ces-q1", impressions: 3, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
|
||||
expect(summary[0].responseCount).toBe(3);
|
||||
|
||||
// CES average = (7 + 6 + 1) / 3 = 4.67
|
||||
expect(summary[0].average).toBe(4.67);
|
||||
|
||||
// Verify 7 choices exist (range 7)
|
||||
expect(summary[0].choices).toHaveLength(7);
|
||||
});
|
||||
|
||||
test("getElementSummary handles CES question with dismissed responses", async () => {
|
||||
const question = {
|
||||
id: "ces-q1",
|
||||
type: TSurveyElementTypeEnum.CES,
|
||||
headline: { default: "How easy was it?" },
|
||||
required: false,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "response-1",
|
||||
data: { "ces-q1": 3 },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: { "ces-q1": 5 },
|
||||
finished: true,
|
||||
},
|
||||
{
|
||||
id: "response-2",
|
||||
data: {},
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: { "ces-q1": 2 },
|
||||
finished: true,
|
||||
},
|
||||
] as any;
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "ces-q1", impressions: 2, dropOffCount: 0, dropOffPercentage: 0 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
|
||||
expect(summary[0].responseCount).toBe(1);
|
||||
expect(summary[0].average).toBe(3);
|
||||
expect(summary[0].dismissed.count).toBe(1);
|
||||
});
|
||||
|
||||
test("getElementSummary handles CES question with no valid responses", async () => {
|
||||
const question = {
|
||||
id: "ces-q1",
|
||||
type: TSurveyElementTypeEnum.CES,
|
||||
headline: { default: "How easy was it?" },
|
||||
required: true,
|
||||
scale: "number",
|
||||
range: 5,
|
||||
};
|
||||
|
||||
const survey = {
|
||||
id: "survey-1",
|
||||
blocks: [{ id: "block1", name: "Block 1", elements: [question] }],
|
||||
questions: [],
|
||||
languages: [],
|
||||
welcomeCard: { enabled: false },
|
||||
} as unknown as TSurvey;
|
||||
|
||||
const responses = [
|
||||
{
|
||||
id: "response-1",
|
||||
data: { "other-q": "value" },
|
||||
updatedAt: new Date(),
|
||||
contact: null,
|
||||
contactAttributes: {},
|
||||
language: null,
|
||||
ttc: {},
|
||||
finished: true,
|
||||
},
|
||||
];
|
||||
|
||||
const dropOff = [
|
||||
{ elementId: "ces-q1", impressions: 1, dropOffCount: 1, dropOffPercentage: 100 },
|
||||
] as unknown as TSurveySummary["dropOff"];
|
||||
|
||||
const summary: any = await getElementSummary(
|
||||
survey,
|
||||
getElementsFromBlocks(survey.blocks),
|
||||
responses,
|
||||
dropOff
|
||||
);
|
||||
|
||||
expect(summary).toHaveLength(1);
|
||||
expect(summary[0].type).toBe(TSurveyElementTypeEnum.CES);
|
||||
expect(summary[0].responseCount).toBe(0);
|
||||
expect(summary[0].average).toBe(0);
|
||||
expect(summary[0].choices.every((c: any) => c.count === 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
+82
-62
@@ -25,7 +25,6 @@ import {
|
||||
TSurveyElementSummaryOpenText,
|
||||
TSurveyElementSummaryPictureSelection,
|
||||
TSurveyElementSummaryRanking,
|
||||
TSurveyElementSummaryRating,
|
||||
TSurveyLanguage,
|
||||
TSurveySummary,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
@@ -272,6 +271,49 @@ const checkForI18n = (
|
||||
return responseData[id];
|
||||
};
|
||||
|
||||
const computeNumericScaleStats = (
|
||||
elementId: string,
|
||||
range: number,
|
||||
responses: TSurveySummaryResponse[]
|
||||
): {
|
||||
choices: { rating: number; count: number; percentage: number }[];
|
||||
choiceCountMap: Record<number, number>;
|
||||
totalResponseCount: number;
|
||||
totalRating: number;
|
||||
dismissed: number;
|
||||
average: number;
|
||||
} => {
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
for (let i = 1; i <= range; i++) {
|
||||
choiceCountMap[i] = 0;
|
||||
}
|
||||
|
||||
let totalResponseCount = 0;
|
||||
let totalRating = 0;
|
||||
let dismissed = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[elementId];
|
||||
if (typeof answer === "number") {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[answer]++;
|
||||
totalRating += answer;
|
||||
} else if (response.ttc && response.ttc[elementId] > 0) {
|
||||
dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
const choices = Object.entries(choiceCountMap).map(([label, count]) => ({
|
||||
rating: Number.parseInt(label),
|
||||
count,
|
||||
percentage: totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
}));
|
||||
|
||||
const average = totalResponseCount > 0 ? convertFloatTo2Decimal(totalRating / totalResponseCount) : 0;
|
||||
|
||||
return { choices, choiceCountMap, totalResponseCount, totalRating, dismissed, average };
|
||||
};
|
||||
|
||||
export const getElementSummary = async (
|
||||
survey: TSurvey,
|
||||
elements: TSurveyElement[],
|
||||
@@ -472,72 +514,16 @@ export const getElementSummary = async (
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.Rating: {
|
||||
let values: TSurveyElementSummaryRating["choices"] = [];
|
||||
const choiceCountMap: Record<number, number> = {};
|
||||
const range = element.range;
|
||||
|
||||
for (let i = 1; i <= range; i++) {
|
||||
choiceCountMap[i] = 0;
|
||||
}
|
||||
|
||||
let totalResponseCount = 0;
|
||||
let totalRating = 0;
|
||||
let dismissed = 0;
|
||||
|
||||
responses.forEach((response) => {
|
||||
const answer = response.data[element.id];
|
||||
if (typeof answer === "number") {
|
||||
totalResponseCount++;
|
||||
choiceCountMap[answer]++;
|
||||
totalRating += answer;
|
||||
} else if (response.ttc && response.ttc[element.id] > 0) {
|
||||
dismissed++;
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(choiceCountMap).forEach(([label, count]) => {
|
||||
values.push({
|
||||
rating: Number.parseInt(label),
|
||||
count,
|
||||
percentage:
|
||||
totalResponseCount > 0 ? convertFloatTo2Decimal((count / totalResponseCount) * 100) : 0,
|
||||
});
|
||||
});
|
||||
|
||||
// Calculate CSAT based on range
|
||||
let satisfiedCount = 0;
|
||||
if (range === 3) {
|
||||
satisfiedCount = choiceCountMap[3] || 0;
|
||||
} else if (range === 4) {
|
||||
satisfiedCount = (choiceCountMap[3] || 0) + (choiceCountMap[4] || 0);
|
||||
} else if (range === 5) {
|
||||
satisfiedCount = (choiceCountMap[4] || 0) + (choiceCountMap[5] || 0);
|
||||
} else if (range === 6) {
|
||||
satisfiedCount = (choiceCountMap[5] || 0) + (choiceCountMap[6] || 0);
|
||||
} else if (range === 7) {
|
||||
satisfiedCount = (choiceCountMap[6] || 0) + (choiceCountMap[7] || 0);
|
||||
} else if (range === 10) {
|
||||
satisfiedCount = (choiceCountMap[8] || 0) + (choiceCountMap[9] || 0) + (choiceCountMap[10] || 0);
|
||||
}
|
||||
const satisfiedPercentage =
|
||||
totalResponseCount > 0 ? Math.round((satisfiedCount / totalResponseCount) * 100) : 0;
|
||||
const stats = computeNumericScaleStats(element.id, element.range, responses);
|
||||
|
||||
summary.push({
|
||||
type: element.type,
|
||||
element,
|
||||
average: convertFloatTo2Decimal(totalRating / totalResponseCount) || 0,
|
||||
responseCount: totalResponseCount,
|
||||
choices: values,
|
||||
dismissed: {
|
||||
count: dismissed,
|
||||
},
|
||||
csat: {
|
||||
satisfiedCount,
|
||||
satisfiedPercentage,
|
||||
},
|
||||
average: stats.average,
|
||||
responseCount: stats.totalResponseCount,
|
||||
choices: stats.choices,
|
||||
dismissed: { count: stats.dismissed },
|
||||
});
|
||||
|
||||
values = [];
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.NPS: {
|
||||
@@ -612,6 +598,40 @@ export const getElementSummary = async (
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CSAT: {
|
||||
const stats = computeNumericScaleStats(element.id, element.range, responses);
|
||||
|
||||
// CSAT: top 2 ratings out of 5 are "satisfied"
|
||||
const satisfiedCount = (stats.choiceCountMap[4] || 0) + (stats.choiceCountMap[5] || 0);
|
||||
const satisfiedPercentage =
|
||||
stats.totalResponseCount > 0
|
||||
? convertFloatTo2Decimal((satisfiedCount / stats.totalResponseCount) * 100)
|
||||
: 0;
|
||||
|
||||
summary.push({
|
||||
type: element.type,
|
||||
element,
|
||||
average: stats.average,
|
||||
responseCount: stats.totalResponseCount,
|
||||
choices: stats.choices,
|
||||
dismissed: { count: stats.dismissed },
|
||||
csat: { satisfiedCount, satisfiedPercentage },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CES: {
|
||||
const stats = computeNumericScaleStats(element.id, element.range, responses);
|
||||
|
||||
summary.push({
|
||||
type: element.type,
|
||||
element,
|
||||
average: stats.average,
|
||||
responseCount: stats.totalResponseCount,
|
||||
choices: stats.choices,
|
||||
dismissed: { count: stats.dismissed },
|
||||
});
|
||||
break;
|
||||
}
|
||||
case TSurveyElementTypeEnum.CTA: {
|
||||
// Only calculate summary for CTA elements with external buttons (CTR tracking is only meaningful for external links)
|
||||
if (!element.buttonExternal) {
|
||||
|
||||
+1
-1
@@ -287,7 +287,7 @@ export const ElementFilterComboBox = ({
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none">
|
||||
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md bg-white shadow-md outline-none animate-in">
|
||||
<CommandList className="max-h-52">
|
||||
<CommandInput
|
||||
value={searchQuery}
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
|
||||
<div className="absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none animate-in">
|
||||
<CommandList className="max-h-[600px]">
|
||||
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
|
||||
{options?.map((data) => (
|
||||
|
||||
+8
-10
@@ -4,7 +4,6 @@ import { useRouter } from "next/navigation";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { useWorkspaceContext } from "@/app/(app)/workspaces/[workspaceId]/context/workspace-context";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { updateSurveyAction } from "@/modules/survey/editor/actions";
|
||||
import {
|
||||
@@ -22,18 +21,19 @@ interface SurveyStatusDropdownProps {
|
||||
}
|
||||
|
||||
export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: SurveyStatusDropdownProps) => {
|
||||
const { workspace } = useWorkspaceContext();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const isScheduled = survey.status === "paused" && survey.publishOn !== null;
|
||||
|
||||
const handleStatusChange = async (status: TSurvey["status"]) => {
|
||||
const updateSurveyActionResponse = await updateSurveyAction({ ...survey, status });
|
||||
|
||||
if (updateSurveyActionResponse?.data) {
|
||||
const resultingStatus = updateSurveyActionResponse.data.status;
|
||||
const { publishOn, status: resultingStatus } = updateSurveyActionResponse.data;
|
||||
const isResultScheduled = resultingStatus === "paused" && publishOn !== null;
|
||||
const statusToToastMessage: Partial<Record<TSurvey["status"], string>> = {
|
||||
inProgress: t("common.survey_live"),
|
||||
paused: t("common.survey_paused"),
|
||||
paused: isResultScheduled ? t("common.survey_scheduled") : t("common.survey_paused"),
|
||||
completed: t("common.survey_completed"),
|
||||
};
|
||||
|
||||
@@ -68,12 +68,10 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
|
||||
<SelectTrigger className="w-[170px] bg-white md:w-[200px]">
|
||||
<SelectValue>
|
||||
<div className="flex items-center">
|
||||
{(survey.type === "link" || workspace.appSetupCompleted) && (
|
||||
<SurveyStatusIndicator status={survey.status} />
|
||||
)}
|
||||
<SurveyStatusIndicator status={survey.status} isScheduled={isScheduled} />
|
||||
<span className="ml-2 text-sm text-slate-700">
|
||||
{survey.status === "inProgress" && t("common.in_progress")}
|
||||
{survey.status === "paused" && t("common.paused")}
|
||||
{survey.status === "paused" && (isScheduled ? t("common.scheduled") : t("common.paused"))}
|
||||
{survey.status === "completed" && t("common.completed")}
|
||||
</span>
|
||||
</div>
|
||||
@@ -88,8 +86,8 @@ export const SurveyStatusDropdown = ({ updateLocalSurveyStatus, survey }: Survey
|
||||
</SelectItem>
|
||||
<SelectItem className="group font-normal hover:text-slate-900" value="paused">
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<SurveyStatusIndicator status={"paused"} />
|
||||
{t("common.paused")}
|
||||
<SurveyStatusIndicator status={"paused"} isScheduled={isScheduled} />
|
||||
{isScheduled ? t("common.scheduled") : t("common.paused")}
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem className="group font-normal hover:text-slate-900" value="completed">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { SurveysQueryClientProvider } from "./query-client-provider";
|
||||
|
||||
const SurveysLayout = ({ children }: { children: ReactNode }) => {
|
||||
return <SurveysQueryClientProvider>{children}</SurveysQueryClientProvider>;
|
||||
};
|
||||
|
||||
export default SurveysLayout;
|
||||
@@ -0,0 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
export const SurveysQueryClientProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [queryClient] = useState(() => new QueryClient());
|
||||
|
||||
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
|
||||
|
||||
interface UnifyConfigNavigationProps {
|
||||
workspaceId: string;
|
||||
activeId?: string;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
export const UnifyConfigNavigation = ({
|
||||
workspaceId,
|
||||
activeId: activeIdProp,
|
||||
loading,
|
||||
}: UnifyConfigNavigationProps) => {
|
||||
const { t } = useTranslation();
|
||||
const baseHref = `/workspaces/${workspaceId}/unify`;
|
||||
|
||||
const activeId = activeIdProp ?? "feedback-records";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
id: "feedback-records",
|
||||
label: t("workspace.unify.feedback_records"),
|
||||
href: `${baseHref}/feedback-records`,
|
||||
},
|
||||
{
|
||||
id: "topics-subtopics",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{t("workspace.unify.topics_and_subtopics")}
|
||||
<Badge text={t("common.soon")} type="gray" size="tiny" />
|
||||
</span>
|
||||
),
|
||||
disabled: true,
|
||||
},
|
||||
];
|
||||
|
||||
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { AuthorizationError } from "@formbricks/types/errors";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { createFeedbackRecord, retrieveFeedbackRecord, updateFeedbackRecord } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordCreateParams, FeedbackRecordUpdateParams } from "@/modules/hub/types";
|
||||
|
||||
const ZFeedbackRecordId = z.uuid();
|
||||
|
||||
const ZFeedbackRecordFieldType = z.enum([
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
]);
|
||||
|
||||
const ZFeedbackRecordMetadata = z.record(z.string(), z.unknown());
|
||||
|
||||
const ZFeedbackRecordCreateInput = z.object({
|
||||
submission_id: z.string().min(1),
|
||||
tenant_id: ZId,
|
||||
source_type: z.string().min(1),
|
||||
field_id: z.string().min(1),
|
||||
field_type: ZFeedbackRecordFieldType,
|
||||
collected_at: z.iso.datetime().optional(),
|
||||
source_id: z.string().optional().nullable(),
|
||||
source_name: z.string().optional().nullable(),
|
||||
field_label: z.string().optional().nullable(),
|
||||
field_group_id: z.string().optional(),
|
||||
field_group_label: z.string().optional().nullable(),
|
||||
value_text: z.string().optional().nullable(),
|
||||
value_number: z.number().optional(),
|
||||
value_boolean: z.boolean().optional(),
|
||||
value_date: z.iso.datetime().optional(),
|
||||
metadata: ZFeedbackRecordMetadata.optional(),
|
||||
language: z.string().optional(),
|
||||
user_identifier: z.string().optional(),
|
||||
});
|
||||
|
||||
const ZFeedbackRecordUpdateInput = z
|
||||
.object({
|
||||
value_text: z.string().optional().nullable(),
|
||||
value_number: z.number().optional().nullable(),
|
||||
value_boolean: z.boolean().optional().nullable(),
|
||||
value_date: z.iso.datetime().optional().nullable(),
|
||||
language: z.string().optional().nullable(),
|
||||
metadata: ZFeedbackRecordMetadata.optional(),
|
||||
user_identifier: z.string().optional().nullable(),
|
||||
})
|
||||
.refine(
|
||||
(value) => Object.values(value).some((entry) => entry !== undefined),
|
||||
"At least one field must be provided for update"
|
||||
);
|
||||
|
||||
const ZRetrieveFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
});
|
||||
|
||||
const ZCreateFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordInput: ZFeedbackRecordCreateInput,
|
||||
});
|
||||
|
||||
const ZUpdateFeedbackRecordAction = z.object({
|
||||
workspaceId: ZId,
|
||||
recordId: ZFeedbackRecordId,
|
||||
updateInput: ZFeedbackRecordUpdateInput,
|
||||
});
|
||||
|
||||
const ensureAccess = async (
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
minPermission: "read" | "readWrite"
|
||||
): Promise<void> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission,
|
||||
workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const getWorkspaceDirectoryIds = async (workspaceId: string): Promise<Set<string>> => {
|
||||
const directories = await getFeedbackRecordDirectoriesByWorkspaceId(workspaceId);
|
||||
return new Set(directories.map((directory) => directory.id));
|
||||
};
|
||||
|
||||
const assertWorkspaceDirectoryAccess = (directoryIds: Set<string>, tenantId: string): void => {
|
||||
if (!directoryIds.has(tenantId)) {
|
||||
throw new AuthorizationError("Invalid feedback record directory for this workspace");
|
||||
}
|
||||
};
|
||||
|
||||
export const retrieveFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZRetrieveFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZRetrieveFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "read");
|
||||
|
||||
const recordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!recordResult.data || recordResult.error) {
|
||||
throw new Error(recordResult.error?.message || "Failed to retrieve feedback record");
|
||||
}
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, recordResult.data.tenant_id);
|
||||
|
||||
return recordResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const createFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZCreateFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, parsedInput.recordInput.tenant_id);
|
||||
|
||||
const createResult = await createFeedbackRecord(
|
||||
parsedInput.recordInput as unknown as FeedbackRecordCreateParams
|
||||
);
|
||||
if (!createResult.data || createResult.error) {
|
||||
throw new Error(createResult.error?.message || "Failed to create feedback record");
|
||||
}
|
||||
|
||||
return createResult.data;
|
||||
}
|
||||
);
|
||||
|
||||
export const updateFeedbackRecordAction = authenticatedActionClient
|
||||
.inputSchema(ZUpdateFeedbackRecordAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateFeedbackRecordAction>;
|
||||
}) => {
|
||||
await ensureAccess(ctx.user.id, parsedInput.workspaceId, "readWrite");
|
||||
|
||||
const currentRecordResult = await retrieveFeedbackRecord(parsedInput.recordId);
|
||||
if (!currentRecordResult.data || currentRecordResult.error) {
|
||||
throw new Error(currentRecordResult.error?.message || "Failed to retrieve feedback record");
|
||||
}
|
||||
|
||||
const workspaceDirectoryIds = await getWorkspaceDirectoryIds(parsedInput.workspaceId);
|
||||
assertWorkspaceDirectoryAccess(workspaceDirectoryIds, currentRecordResult.data.tenant_id);
|
||||
|
||||
const updatePayload = Object.fromEntries(
|
||||
Object.entries(parsedInput.updateInput).filter(([, value]) => value !== undefined)
|
||||
) as unknown as FeedbackRecordUpdateParams;
|
||||
|
||||
const updateResult = await updateFeedbackRecord(parsedInput.recordId, updatePayload);
|
||||
if (!updateResult.data || updateResult.error) {
|
||||
throw new Error(updateResult.error?.message || "Failed to update feedback record");
|
||||
}
|
||||
|
||||
return updateResult.data;
|
||||
}
|
||||
);
|
||||
+988
@@ -0,0 +1,988 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
import { z } from "zod";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { AlertDialog } from "@/modules/ui/components/alert-dialog";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/modules/ui/components/sheet";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import {
|
||||
createFeedbackRecordAction,
|
||||
retrieveFeedbackRecordAction,
|
||||
updateFeedbackRecordAction,
|
||||
} from "./actions";
|
||||
|
||||
type FeedbackRecordDrawerMode = "create" | "edit";
|
||||
|
||||
interface FeedbackRecordFormDrawerProps {
|
||||
mode: FeedbackRecordDrawerMode;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
recordId?: string;
|
||||
onSuccess: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
const FIELD_TYPE_OPTIONS = [
|
||||
"text",
|
||||
"categorical",
|
||||
"nps",
|
||||
"csat",
|
||||
"ces",
|
||||
"rating",
|
||||
"number",
|
||||
"boolean",
|
||||
"date",
|
||||
] as const;
|
||||
|
||||
const SOURCE_TYPE_PRESET_OPTIONS = [
|
||||
"survey",
|
||||
"review",
|
||||
"feedback_form",
|
||||
"support",
|
||||
"social",
|
||||
"interview",
|
||||
"usability_test",
|
||||
"nps_campaign",
|
||||
] as const;
|
||||
|
||||
const SOURCE_TYPE_CUSTOM_VALUE = "__custom__";
|
||||
|
||||
const ZMetadataEntry = z.object({
|
||||
key: z.string().trim().min(1),
|
||||
value: z.string(),
|
||||
});
|
||||
|
||||
const ZFeedbackRecordFormValues = z.object({
|
||||
id: z.string().optional(),
|
||||
tenant_id: z.string().min(1),
|
||||
submission_id: z.string().min(1),
|
||||
collected_at: z.string().min(1),
|
||||
created_at: z.string().optional(),
|
||||
updated_at: z.string().optional(),
|
||||
source_type: z.string().min(1),
|
||||
source_id: z.string().optional(),
|
||||
source_name: z.string().optional(),
|
||||
field_id: z.string().min(1),
|
||||
field_label: z.string().optional(),
|
||||
field_type: z.enum(FIELD_TYPE_OPTIONS),
|
||||
field_group_id: z.string().optional(),
|
||||
field_group_label: z.string().optional(),
|
||||
value_text: z.string().optional(),
|
||||
value_number: z.string().optional(),
|
||||
value_boolean: z.boolean().optional(),
|
||||
value_date: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
user_identifier: z.string().optional(),
|
||||
metadataEntries: z.array(ZMetadataEntry),
|
||||
});
|
||||
|
||||
type TFeedbackRecordFormValues = z.infer<typeof ZFeedbackRecordFormValues>;
|
||||
|
||||
const getValueFieldByType = (
|
||||
fieldType: TFeedbackRecordFormValues["field_type"]
|
||||
): "value_text" | "value_number" | "value_boolean" | "value_date" => {
|
||||
switch (fieldType) {
|
||||
case "boolean":
|
||||
return "value_boolean";
|
||||
case "date":
|
||||
return "value_date";
|
||||
case "nps":
|
||||
case "csat":
|
||||
case "ces":
|
||||
case "rating":
|
||||
case "number":
|
||||
return "value_number";
|
||||
default:
|
||||
return "value_text";
|
||||
}
|
||||
};
|
||||
|
||||
const toLocalDateTimeInput = (isoDate: string): string => {
|
||||
const date = new Date(isoDate);
|
||||
if (!Number.isFinite(date.getTime())) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
const toISOOrUndefined = (dateTimeValue: string | undefined): string | undefined => {
|
||||
if (!dateTimeValue) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = new Date(dateTimeValue);
|
||||
if (!Number.isFinite(parsed.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return parsed.toISOString();
|
||||
};
|
||||
|
||||
const getCreateDefaults = (directories: { id: string; name: string }[]): TFeedbackRecordFormValues => {
|
||||
const now = new Date();
|
||||
const defaultDirectoryId = directories[0]?.id ?? "";
|
||||
|
||||
return {
|
||||
id: "",
|
||||
tenant_id: defaultDirectoryId,
|
||||
submission_id: uuidv7(),
|
||||
collected_at: toLocalDateTimeInput(now.toISOString()),
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
source_type: "survey",
|
||||
source_id: "",
|
||||
source_name: "",
|
||||
field_id: "",
|
||||
field_label: "",
|
||||
field_type: "text",
|
||||
field_group_id: "",
|
||||
field_group_label: "",
|
||||
value_text: "",
|
||||
value_number: "",
|
||||
value_boolean: undefined,
|
||||
value_date: "",
|
||||
language: "",
|
||||
user_identifier: "",
|
||||
metadataEntries: [],
|
||||
};
|
||||
};
|
||||
|
||||
const mapRecordToValues = (record: FeedbackRecordData): TFeedbackRecordFormValues => {
|
||||
const metadataEntries = Object.entries(record.metadata ?? {})
|
||||
.filter(([, value]) => typeof value === "string")
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: value as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
tenant_id: record.tenant_id,
|
||||
submission_id: record.submission_id,
|
||||
collected_at: toLocalDateTimeInput(record.collected_at),
|
||||
created_at: record.created_at ? toLocalDateTimeInput(record.created_at) : "",
|
||||
updated_at: record.updated_at ? toLocalDateTimeInput(record.updated_at) : "",
|
||||
source_type: record.source_type,
|
||||
source_id: record.source_id ?? "",
|
||||
source_name: record.source_name ?? "",
|
||||
field_id: record.field_id,
|
||||
field_label: record.field_label ?? "",
|
||||
field_type: record.field_type,
|
||||
field_group_id: record.field_group_id ?? "",
|
||||
field_group_label: record.field_group_label ?? "",
|
||||
value_text: record.value_text ?? "",
|
||||
value_number: record.value_number == null ? "" : String(record.value_number),
|
||||
value_boolean: record.value_boolean,
|
||||
value_date: record.value_date ? toLocalDateTimeInput(record.value_date) : "",
|
||||
language: record.language ?? "",
|
||||
user_identifier: record.user_identifier ?? "",
|
||||
metadataEntries,
|
||||
};
|
||||
};
|
||||
|
||||
const getReadOnlyMetadataEntries = (record: FeedbackRecordData): { key: string; value: string }[] => {
|
||||
return Object.entries(record.metadata ?? {})
|
||||
.filter(([, value]) => typeof value !== "string")
|
||||
.map(([key, value]) => ({
|
||||
key,
|
||||
value: JSON.stringify(value),
|
||||
}));
|
||||
};
|
||||
|
||||
const parseNumberValue = (value: string): number | null => {
|
||||
if (value.trim() === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const formatSourceType = (sourceType: string, t: (key: string) => string): string => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
case "formbricks_survey":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
|
||||
export const FeedbackRecordFormDrawer = ({
|
||||
mode,
|
||||
open,
|
||||
onOpenChange,
|
||||
workspaceId,
|
||||
directories,
|
||||
canWrite,
|
||||
recordId,
|
||||
onSuccess,
|
||||
}: Readonly<FeedbackRecordFormDrawerProps>) => {
|
||||
const { t } = useTranslation();
|
||||
const [record, setRecord] = useState<FeedbackRecordData | null>(null);
|
||||
const [isLoadingRecord, setIsLoadingRecord] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [isDiscardDialogOpen, setIsDiscardDialogOpen] = useState(false);
|
||||
|
||||
const defaultValues = useMemo(() => getCreateDefaults(directories), [directories]);
|
||||
|
||||
const form = useForm<TFeedbackRecordFormValues>({
|
||||
resolver: zodResolver(ZFeedbackRecordFormValues),
|
||||
defaultValues,
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "metadataEntries",
|
||||
});
|
||||
|
||||
const fieldType = form.watch("field_type");
|
||||
const selectedValueField = getValueFieldByType(fieldType);
|
||||
const isEditMode = mode === "edit";
|
||||
const isReadOnly = isEditMode && !canWrite;
|
||||
|
||||
const [sourceTypeMode, setSourceTypeMode] = useState<string>("survey");
|
||||
const [customSourceType, setCustomSourceType] = useState("");
|
||||
|
||||
const readOnlyMetadataEntries = useMemo(() => (record ? getReadOnlyMetadataEntries(record) : []), [record]);
|
||||
|
||||
const resetForCreate = useCallback(() => {
|
||||
const nextDefaults = getCreateDefaults(directories);
|
||||
form.reset(nextDefaults);
|
||||
setRecord(null);
|
||||
setSourceTypeMode(nextDefaults.source_type);
|
||||
setCustomSourceType("");
|
||||
}, [directories, form]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (mode === "create") {
|
||||
resetForCreate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!recordId) return;
|
||||
|
||||
const loadRecord = async () => {
|
||||
setIsLoadingRecord(true);
|
||||
const result = await retrieveFeedbackRecordAction({ workspaceId, recordId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result) || t("workspace.unify.failed_to_load_feedback_records"));
|
||||
setIsLoadingRecord(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setRecord(result.data);
|
||||
form.reset(mapRecordToValues(result.data));
|
||||
setSourceTypeMode(
|
||||
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never)
|
||||
? result.data.source_type
|
||||
: SOURCE_TYPE_CUSTOM_VALUE
|
||||
);
|
||||
setCustomSourceType(
|
||||
SOURCE_TYPE_PRESET_OPTIONS.includes(result.data.source_type as never) ? "" : result.data.source_type
|
||||
);
|
||||
setIsLoadingRecord(false);
|
||||
};
|
||||
|
||||
void loadRecord();
|
||||
}, [form, mode, open, recordId, resetForCreate, t, workspaceId]);
|
||||
|
||||
const requestClose = useCallback(() => {
|
||||
if (form.formState.isDirty && !isSubmitting) {
|
||||
setIsDiscardDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
}, [form.formState.isDirty, isSubmitting, onOpenChange]);
|
||||
|
||||
const handleDrawerOpenChange = useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (nextOpen) {
|
||||
onOpenChange(true);
|
||||
return;
|
||||
}
|
||||
|
||||
requestClose();
|
||||
},
|
||||
[onOpenChange, requestClose]
|
||||
);
|
||||
|
||||
const handleDiscardChanges = () => {
|
||||
setIsDiscardDialogOpen(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const setStrictValueValidationError = (message: string) => {
|
||||
form.setError(selectedValueField, { type: "manual", message });
|
||||
};
|
||||
|
||||
const handleSubmit = form.handleSubmit(async (values) => {
|
||||
form.clearErrors();
|
||||
|
||||
if (mode === "create") {
|
||||
const requiredValueError = t("workspace.unify.feedback_record_value_required");
|
||||
if (selectedValueField === "value_text" && !values.value_text?.trim()) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_number" && parseNumberValue(values.value_number ?? "") == null) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_boolean" && values.value_boolean === undefined) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
if (selectedValueField === "value_date" && !toISOOrUndefined(values.value_date)) {
|
||||
setStrictValueValidationError(requiredValueError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = Object.fromEntries(
|
||||
values.metadataEntries
|
||||
.map((entry) => ({
|
||||
key: entry.key.trim(),
|
||||
value: entry.value,
|
||||
}))
|
||||
.filter((entry) => entry.key.length > 0)
|
||||
.map((entry) => [entry.key, entry.value])
|
||||
);
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const sourceTypeValue =
|
||||
sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE ? customSourceType.trim() : values.source_type;
|
||||
|
||||
const createResult = await createFeedbackRecordAction({
|
||||
workspaceId,
|
||||
recordInput: {
|
||||
submission_id: values.submission_id.trim(),
|
||||
tenant_id: values.tenant_id,
|
||||
source_type: sourceTypeValue,
|
||||
source_id: values.source_id?.trim() ? values.source_id.trim() : null,
|
||||
source_name: values.source_name?.trim() ? values.source_name.trim() : null,
|
||||
field_id: values.field_id.trim(),
|
||||
field_label: values.field_label?.trim() ? values.field_label.trim() : null,
|
||||
field_type: values.field_type,
|
||||
field_group_id: values.field_group_id?.trim() || undefined,
|
||||
field_group_label: values.field_group_label?.trim() ? values.field_group_label.trim() : null,
|
||||
collected_at: toISOOrUndefined(values.collected_at),
|
||||
value_text: selectedValueField === "value_text" ? (values.value_text ?? "") : null,
|
||||
value_number:
|
||||
selectedValueField === "value_number"
|
||||
? (parseNumberValue(values.value_number ?? "") ?? undefined)
|
||||
: undefined,
|
||||
value_boolean: selectedValueField === "value_boolean" ? values.value_boolean : undefined,
|
||||
value_date: selectedValueField === "value_date" ? toISOOrUndefined(values.value_date) : undefined,
|
||||
metadata: Object.keys(metadata).length > 0 ? metadata : undefined,
|
||||
language: values.language?.trim() || undefined,
|
||||
user_identifier: values.user_identifier?.trim() || undefined,
|
||||
},
|
||||
});
|
||||
|
||||
if (!createResult?.data) {
|
||||
toast.error(getFormattedErrorMessage(createResult));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!recordId) {
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const preservedMetadata = Object.fromEntries(
|
||||
Object.entries(record?.metadata ?? {}).filter(([, value]) => typeof value !== "string")
|
||||
);
|
||||
|
||||
const updatePayload: Record<string, unknown> = {
|
||||
language: values.language?.trim() || null,
|
||||
user_identifier: values.user_identifier?.trim() || null,
|
||||
metadata: { ...preservedMetadata, ...metadata },
|
||||
};
|
||||
|
||||
if (selectedValueField === "value_text") {
|
||||
updatePayload.value_text = values.value_text?.trim() ?? "";
|
||||
} else if (selectedValueField === "value_number") {
|
||||
updatePayload.value_number = parseNumberValue(values.value_number ?? "");
|
||||
} else if (selectedValueField === "value_boolean") {
|
||||
updatePayload.value_boolean = values.value_boolean ?? null;
|
||||
} else if (selectedValueField === "value_date") {
|
||||
updatePayload.value_date = toISOOrUndefined(values.value_date) ?? null;
|
||||
}
|
||||
|
||||
const updateResult = await updateFeedbackRecordAction({
|
||||
workspaceId,
|
||||
recordId,
|
||||
updateInput: updatePayload as never,
|
||||
});
|
||||
|
||||
if (!updateResult?.data) {
|
||||
toast.error(getFormattedErrorMessage(updateResult));
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
toast.success(
|
||||
mode === "create"
|
||||
? t("workspace.unify.feedback_record_created_successfully")
|
||||
: t("workspace.unify.feedback_record_updated_successfully")
|
||||
);
|
||||
await onSuccess();
|
||||
onOpenChange(false);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
});
|
||||
|
||||
const drawerTitle =
|
||||
mode === "create"
|
||||
? t("workspace.unify.add_feedback_record")
|
||||
: t("workspace.unify.feedback_record_details");
|
||||
|
||||
const drawerDescription =
|
||||
mode === "create"
|
||||
? t("workspace.unify.add_feedback_record_description")
|
||||
: t("workspace.unify.feedback_record_details_description");
|
||||
|
||||
const valueBooleanStatus = form.watch("value_boolean");
|
||||
let valueBooleanLabel = t("common.not_set");
|
||||
if (valueBooleanStatus === true) {
|
||||
valueBooleanLabel = t("common.yes");
|
||||
} else if (valueBooleanStatus === false) {
|
||||
valueBooleanLabel = t("common.no");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Sheet open={open} onOpenChange={handleDrawerOpenChange}>
|
||||
<SheetContent className="w-full overflow-y-auto bg-white px-5 sm:max-w-2xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{drawerTitle}</SheetTitle>
|
||||
<SheetDescription>{drawerDescription}</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
{isLoadingRecord ? (
|
||||
<div className="py-8 text-sm text-slate-500">{t("common.loading")}</div>
|
||||
) : (
|
||||
<FormProvider {...form}>
|
||||
<form className="space-y-4 py-4" onSubmit={handleSubmit}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tenant_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.feedback_record_directory")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("workspace.unify.select_feedback_record_directory")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{directories.map((directory) => (
|
||||
<SelectItem key={directory.id} value={directory.id}>
|
||||
{directory.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="submission_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.submission_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="collected_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.collected_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="datetime-local" disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="created_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.created_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="updated_at"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.updated_at")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled placeholder={t("workspace.unify.auto_generated")} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEditMode ? (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} value={formatSourceType(field.value, t)} disabled />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<FormLabel>{t("workspace.unify.source_type")}</FormLabel>
|
||||
<Select
|
||||
value={sourceTypeMode}
|
||||
onValueChange={(value) => {
|
||||
setSourceTypeMode(value);
|
||||
if (value !== SOURCE_TYPE_CUSTOM_VALUE) {
|
||||
form.setValue("source_type", value, { shouldDirty: true });
|
||||
}
|
||||
}}
|
||||
disabled={!canWrite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_feedback_record_source_type")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SOURCE_TYPE_PRESET_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={SOURCE_TYPE_CUSTOM_VALUE}>
|
||||
{t("workspace.unify.custom_source_type")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{sourceTypeMode === SOURCE_TYPE_CUSTOM_VALUE && (
|
||||
<Input
|
||||
value={customSourceType}
|
||||
onChange={(event) => {
|
||||
setCustomSourceType(event.target.value);
|
||||
form.setValue("source_type", event.target.value, { shouldDirty: true });
|
||||
}}
|
||||
placeholder={t("workspace.unify.custom_source_type_placeholder")}
|
||||
disabled={!canWrite}
|
||||
/>
|
||||
)}
|
||||
<FormError>{form.formState.errors.source_type?.message}</FormError>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="source_name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_type"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_type")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select
|
||||
value={field.value}
|
||||
onValueChange={(value) =>
|
||||
field.onChange(value as TFeedbackRecordFormValues["field_type"])
|
||||
}
|
||||
disabled={isEditMode || !canWrite}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FIELD_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option} value={option}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_group_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_group_id")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="field_group_label"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.field_group_label")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={isEditMode || !canWrite} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_text"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_text")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
disabled={selectedValueField !== "value_text" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_number"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_number")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="number"
|
||||
step="any"
|
||||
disabled={selectedValueField !== "value_number" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_date"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_date")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
type="datetime-local"
|
||||
disabled={selectedValueField !== "value_date" || isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="value_boolean"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.value_boolean")}</FormLabel>
|
||||
<FormControl>
|
||||
<div className="flex items-center gap-3 rounded-md border border-slate-200 px-3 py-2">
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={(checked) => field.onChange(checked)}
|
||||
disabled={selectedValueField !== "value_boolean" || isReadOnly || !canWrite}
|
||||
/>
|
||||
<span className="text-sm text-slate-600">{valueBooleanLabel}</span>
|
||||
</div>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormError>{form.formState.errors[selectedValueField]?.message}</FormError>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="language"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("common.language")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!canWrite || isReadOnly} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_identifier"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.user_identifier")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} disabled={!canWrite || isReadOnly} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel>{t("workspace.unify.metadata")}</FormLabel>
|
||||
{canWrite && !isReadOnly && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => append({ key: "", value: "" })}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{fields.map((field, index) => (
|
||||
<div key={field.id} className="grid grid-cols-[1fr_1fr_auto] gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadataEntries.${index}.key`}
|
||||
render={({ field: entryField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...entryField}
|
||||
placeholder={t("workspace.unify.metadata_key")}
|
||||
disabled={isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name={`metadataEntries.${index}.value`}
|
||||
render={({ field: entryField }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...entryField}
|
||||
placeholder={t("workspace.unify.metadata_value")}
|
||||
disabled={isReadOnly || !canWrite}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{canWrite && !isReadOnly && (
|
||||
<Button type="button" variant="outline" onClick={() => remove(index)}>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{readOnlyMetadataEntries.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.metadata_read_only_entries")}
|
||||
</p>
|
||||
{readOnlyMetadataEntries.map((entry) => (
|
||||
<div
|
||||
key={entry.key}
|
||||
className="grid grid-cols-2 gap-2 rounded-md bg-slate-50 p-2 text-xs">
|
||||
<span className="font-medium text-slate-700">{entry.key}</span>
|
||||
<span className="truncate text-slate-600" title={entry.value}>
|
||||
{entry.value}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
<SheetFooter className="mt-2">
|
||||
<Button variant="outline" onClick={requestClose} disabled={isSubmitting}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
{canWrite && (
|
||||
<Button onClick={handleSubmit} loading={isSubmitting} disabled={isLoadingRecord}>
|
||||
{mode === "create" ? t("workspace.unify.add_feedback_record") : t("common.save")}
|
||||
</Button>
|
||||
)}
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<AlertDialog
|
||||
open={isDiscardDialogOpen}
|
||||
setOpen={setIsDiscardDialogOpen}
|
||||
headerText={t("workspace.unify.discard_feedback_record_changes_title")}
|
||||
mainText={t("workspace.unify.discard_feedback_record_changes_description")}
|
||||
confirmBtnLabel={t("common.discard")}
|
||||
declineBtnLabel={t("common.cancel")}
|
||||
declineBtnVariant="outline"
|
||||
onDecline={() => setIsDiscardDialogOpen(false)}
|
||||
onConfirm={handleDiscardChanges}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { UnifyConfigNavigation } from "../components/UnifyConfigNavigation";
|
||||
import { FeedbackRecordsTable } from "./feedback-records-table";
|
||||
|
||||
interface FeedbackRecordsPageClientProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export function FeedbackRecordsPageClient({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsPageClientProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("workspace.unify.feedback_records")}>
|
||||
<UnifyConfigNavigation workspaceId={workspaceId} activeId="feedback-records" />
|
||||
</PageHeader>
|
||||
|
||||
<FeedbackRecordsTable
|
||||
workspaceId={workspaceId}
|
||||
initialRecords={initialRecords}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
+392
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { TFunction } from "i18next";
|
||||
import {
|
||||
CalendarIcon,
|
||||
ChevronDownIcon,
|
||||
HashIcon,
|
||||
MessageSquareTextIcon,
|
||||
PlusIcon,
|
||||
RefreshCwIcon,
|
||||
ToggleLeftIcon,
|
||||
TypeIcon,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
|
||||
import { formatDateForDisplay, formatDateTimeForDisplay } from "@/lib/utils/datetime";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import type { FeedbackRecordData } from "@/modules/hub/types";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
import { CsvImportModal } from "../sources/components/csv-import-modal";
|
||||
import { FeedbackRecordFormDrawer } from "./feedback-record-form-drawer";
|
||||
|
||||
const RECORDS_PER_PAGE = 50;
|
||||
|
||||
const FIELD_TYPE_ICONS: Record<string, React.ReactNode> = {
|
||||
text: <TypeIcon className="h-3.5 w-3.5" />,
|
||||
categorical: <HashIcon className="h-3.5 w-3.5" />,
|
||||
nps: <HashIcon className="h-3.5 w-3.5" />,
|
||||
csat: <HashIcon className="h-3.5 w-3.5" />,
|
||||
ces: <HashIcon className="h-3.5 w-3.5" />,
|
||||
rating: <HashIcon className="h-3.5 w-3.5" />,
|
||||
number: <HashIcon className="h-3.5 w-3.5" />,
|
||||
boolean: <ToggleLeftIcon className="h-3.5 w-3.5" />,
|
||||
date: <CalendarIcon className="h-3.5 w-3.5" />,
|
||||
};
|
||||
|
||||
const formatValue = (record: FeedbackRecordData, t: TFunction, locale: string): string => {
|
||||
if (record.value_text != null) return record.value_text;
|
||||
if (record.value_number != null) return String(record.value_number);
|
||||
if (record.value_boolean != null) return record.value_boolean ? t("common.yes") : t("common.no");
|
||||
if (record.value_date != null) return formatDateForDisplay(new Date(record.value_date), locale);
|
||||
return "—";
|
||||
};
|
||||
|
||||
const formatSourceType = (sourceType: string, t: TFunction): string => {
|
||||
switch (sourceType) {
|
||||
case "formbricks":
|
||||
case "formbricks_survey":
|
||||
return t("workspace.unify.formbricks_surveys");
|
||||
case "csv":
|
||||
return t("workspace.unify.csv_import");
|
||||
default:
|
||||
return sourceType;
|
||||
}
|
||||
};
|
||||
|
||||
function truncate(str: string, maxLen: number): string {
|
||||
if (str.length <= maxLen) return str;
|
||||
return str.slice(0, maxLen) + "…";
|
||||
}
|
||||
|
||||
interface FeedbackRecordsTableProps {
|
||||
workspaceId: string;
|
||||
initialRecords: FeedbackRecordData[];
|
||||
frdMap: Record<string, string>;
|
||||
csvSources: { id: string; name: string }[];
|
||||
canWrite: boolean;
|
||||
}
|
||||
|
||||
export const FeedbackRecordsTable = ({
|
||||
workspaceId,
|
||||
initialRecords,
|
||||
frdMap,
|
||||
csvSources,
|
||||
canWrite,
|
||||
}: Readonly<FeedbackRecordsTableProps>) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<"create" | "edit">("edit");
|
||||
const [drawerRecordId, setDrawerRecordId] = useState<string | undefined>();
|
||||
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
|
||||
const [csvImportSource, setCsvImportSource] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const directories = useMemo(
|
||||
() =>
|
||||
Object.entries(frdMap)
|
||||
.map(([id, name]) => ({ id, name }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
[frdMap]
|
||||
);
|
||||
const feedbackDirectoryName = useMemo(() => {
|
||||
const directoryNames = Array.from(
|
||||
new Set(
|
||||
records
|
||||
.map((record) => frdMap[record.tenant_id])
|
||||
.filter((directoryName): directoryName is string => Boolean(directoryName))
|
||||
)
|
||||
);
|
||||
|
||||
if (directoryNames.length > 0) {
|
||||
return directoryNames.join(", ");
|
||||
}
|
||||
|
||||
return directories[0]?.name ?? "—";
|
||||
}, [directories, frdMap, records]);
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
setError(null);
|
||||
|
||||
const toastId = toast.loading(t("workspace.unify.refreshing_feedback_records"));
|
||||
const directoryIds = Object.keys(frdMap);
|
||||
const results = await Promise.all(
|
||||
directoryIds.map((frdId) =>
|
||||
listFeedbackRecordsAction({
|
||||
workspaceId,
|
||||
frdId,
|
||||
limit: RECORDS_PER_PAGE,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
const successfulRecords = results.flatMap((result) => result?.data?.data ?? []);
|
||||
|
||||
if (directoryIds.length > 0 && successfulRecords.length === 0) {
|
||||
const firstErrorResult = results.find((result) => !result?.data);
|
||||
const errorMessage = firstErrorResult ? getFormattedErrorMessage(firstErrorResult) : undefined;
|
||||
toast.error(errorMessage ?? t("workspace.unify.failed_to_load_feedback_records"), {
|
||||
id: toastId,
|
||||
});
|
||||
setIsRefreshing(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedRecords = successfulRecords
|
||||
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, RECORDS_PER_PAGE);
|
||||
setRecords(mergedRecords);
|
||||
setIsRefreshing(false);
|
||||
toast.success(t("workspace.unify.feedback_records_refreshed"), { id: toastId });
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex h-48 flex-col items-center justify-center gap-3 px-4 text-center">
|
||||
<MessageSquareTextIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="text-sm text-slate-500">{error}</p>
|
||||
<Button variant="secondary" size="sm" onClick={handleRefresh}>
|
||||
{t("common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isEmpty = records.length === 0 && !isRefreshing;
|
||||
|
||||
const openEditDrawer = (recordId: string) => {
|
||||
setDrawerMode("edit");
|
||||
setDrawerRecordId(recordId);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const openCreateDrawer = () => {
|
||||
setDrawerMode("create");
|
||||
setDrawerRecordId(undefined);
|
||||
setIsDrawerOpen(true);
|
||||
};
|
||||
|
||||
const hasCsvSources = csvSources.length > 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{isEmpty ? (
|
||||
<span />
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.showing_count_loaded", {
|
||||
count: records.length,
|
||||
directoryName: feedbackDirectoryName,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{canWrite &&
|
||||
(hasCsvSources ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" variant="secondary">
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={openCreateDrawer}>
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{csvSources.map((source) => (
|
||||
<DropdownMenuItem
|
||||
key={source.id}
|
||||
onClick={() => {
|
||||
setCsvImportSource(source);
|
||||
}}>
|
||||
{t("workspace.unify.import_via_source_name", { sourceName: source.name })}
|
||||
</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={openCreateDrawer}>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{t("workspace.unify.add_feedback_record")}
|
||||
</Button>
|
||||
))}
|
||||
<Button size="sm" asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/feedback-sources`}>
|
||||
{t("workspace.unify.manage_feedback_sources")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t("workspace.unify.refresh_feedback_records")}>
|
||||
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-sm text-slate-900 [&>th]:font-semibold">
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.collected_at")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.source_name")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_label")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.field_type")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.value")}</th>
|
||||
<th className="whitespace-nowrap px-4 py-3">{t("workspace.unify.user_identifier")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{isEmpty ? (
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_feedback_records")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{records.map((record) => (
|
||||
<FeedbackRecordRow
|
||||
key={record.id}
|
||||
record={record}
|
||||
workspaceId={workspaceId}
|
||||
locale={i18n.resolvedLanguage ?? i18n.language ?? "en-US"}
|
||||
t={t}
|
||||
onClick={() => openEditDrawer(record.id)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FeedbackRecordFormDrawer
|
||||
mode={drawerMode}
|
||||
open={isDrawerOpen}
|
||||
onOpenChange={setIsDrawerOpen}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
canWrite={canWrite}
|
||||
recordId={drawerMode === "edit" ? drawerRecordId : undefined}
|
||||
onSuccess={handleRefresh}
|
||||
/>
|
||||
|
||||
{csvImportSource && (
|
||||
<CsvImportModal
|
||||
open={csvImportSource !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setCsvImportSource(null);
|
||||
}
|
||||
}}
|
||||
connectorId={csvImportSource.id}
|
||||
workspaceId={workspaceId}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedbackRecordRow = ({
|
||||
record,
|
||||
workspaceId,
|
||||
locale,
|
||||
t,
|
||||
onClick,
|
||||
}: {
|
||||
record: FeedbackRecordData;
|
||||
workspaceId: string;
|
||||
locale: string;
|
||||
t: TFunction;
|
||||
onClick: () => void;
|
||||
}) => {
|
||||
const value = formatValue(record, t, locale);
|
||||
const isLongValue = value.length > 60;
|
||||
const isFormbricksSurveySource =
|
||||
(record.source_type === "formbricks" || record.source_type === "formbricks_survey") && !!record.source_id;
|
||||
const surveySummaryHref = `/workspaces/${workspaceId}/surveys/${record.source_id}/summary`;
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="cursor-pointer text-sm text-slate-700 transition-colors hover:bg-slate-50"
|
||||
onClick={onClick}>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
|
||||
{formatDateTimeForDisplay(new Date(record.collected_at), locale)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<Badge text={formatSourceType(record.source_type, t)} type="gray" size="tiny" />
|
||||
</td>
|
||||
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
|
||||
{isFormbricksSurveySource ? (
|
||||
<Link
|
||||
href={surveySummaryHref}
|
||||
className="text-slate-700 underline underline-offset-2 hover:text-slate-900"
|
||||
onClick={(event) => event.stopPropagation()}>
|
||||
{record.source_name ?? "—"}
|
||||
</Link>
|
||||
) : (
|
||||
<span>{record.source_name ?? "—"}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[200px] truncate px-4 py-3" title={record.field_label ?? undefined}>
|
||||
{record.field_label ?? record.field_id}
|
||||
</td>
|
||||
<td className="whitespace-nowrap px-4 py-3">
|
||||
<span className="inline-flex items-center gap-1 text-slate-600">
|
||||
{FIELD_TYPE_ICONS[record.field_type] ?? <HashIcon className="h-3.5 w-3.5" />}
|
||||
{record.field_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-[250px] px-4 py-3">
|
||||
{isLongValue ? (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default truncate">{truncate(value, 60)}</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-sm whitespace-pre-wrap">
|
||||
{value}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : (
|
||||
<span>{value}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="max-w-[120px] truncate px-4 py-3 text-slate-500" title={record.user_identifier}>
|
||||
{record.user_identifier ?? "—"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getConnectorsWithMappings } from "@/lib/connector/service";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { getFeedbackRecordDirectoriesByWorkspaceId } from "@/modules/ee/feedback-record-directory/lib/feedback-record-directory";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import { getWorkspaceAuth } from "@/modules/workspaces/lib/utils";
|
||||
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
|
||||
|
||||
const INITIAL_PAGE_SIZE = 50;
|
||||
|
||||
export default async function UnifyFeedbackRecordsPage(
|
||||
props: Readonly<{ params: Promise<{ workspaceId: string }> }>
|
||||
) {
|
||||
const t = await getTranslate();
|
||||
const params = await props.params;
|
||||
|
||||
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
|
||||
await getWorkspaceAuth(params.workspaceId);
|
||||
|
||||
if (!session) {
|
||||
throw new Error(t("common.session_not_found"));
|
||||
}
|
||||
|
||||
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
|
||||
const canWrite = isOwner || isManager || hasReadWriteAccess || hasManageAccess;
|
||||
if (!hasAccess) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const [frds, connectors] = await Promise.all([
|
||||
getFeedbackRecordDirectoriesByWorkspaceId(params.workspaceId),
|
||||
getConnectorsWithMappings(params.workspaceId),
|
||||
]);
|
||||
|
||||
const results = await Promise.all(
|
||||
frds.map((frd) => listFeedbackRecords({ tenant_id: frd.id, limit: INITIAL_PAGE_SIZE }))
|
||||
);
|
||||
|
||||
// Don't crash if Hub is unreachable — show empty state
|
||||
const successfulResults = results.filter((r) => !r.error);
|
||||
|
||||
const merged = successfulResults
|
||||
.flatMap((r) => r.data?.data ?? [])
|
||||
.sort((a, b) => (a.collected_at < b.collected_at ? 1 : -1))
|
||||
.slice(0, INITIAL_PAGE_SIZE);
|
||||
|
||||
const frdMap = Object.fromEntries(frds.map((f) => [f.id, f.name]));
|
||||
const csvSources = connectors
|
||||
.filter((connector) => connector.type === "csv")
|
||||
.map((connector) => ({ id: connector.id, name: connector.name }));
|
||||
|
||||
return (
|
||||
<FeedbackRecordsPageClient
|
||||
workspaceId={params.workspaceId}
|
||||
initialRecords={merged}
|
||||
frdMap={frdMap}
|
||||
csvSources={csvSources}
|
||||
canWrite={canWrite}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifyPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/unify/feedback-records`);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromWorkspaceId } from "@/lib/utils/helper";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
import { TUnifySurvey } from "./types";
|
||||
|
||||
const ZGetSurveysForUnifyAction = z.object({
|
||||
workspaceId: ZId,
|
||||
});
|
||||
|
||||
export const getSurveysForUnifyAction = authenticatedActionClient
|
||||
.schema(ZGetSurveysForUnifyAction)
|
||||
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
|
||||
const organizationId = await getOrganizationIdFromWorkspaceId(parsedInput.workspaceId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager", "member"],
|
||||
},
|
||||
{
|
||||
type: "workspaceTeam",
|
||||
minPermission: "read",
|
||||
workspaceId: parsedInput.workspaceId,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const surveys = await getSurveys(parsedInput.workspaceId);
|
||||
return surveys.map((survey) => transformToUnifySurvey(survey));
|
||||
});
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
|
||||
export const getConnectorIcon = (type: TConnectorType, className: string) => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return <FormIcon className={className} />;
|
||||
case "csv":
|
||||
return <FileSpreadsheetIcon className={className} />;
|
||||
default:
|
||||
return <FormIcon className={className} />;
|
||||
}
|
||||
};
|
||||
|
||||
export const getConnectorTypeLabelKey = (type: TConnectorType): string => {
|
||||
switch (type) {
|
||||
case "formbricks_survey":
|
||||
return "workspace.unify.formbricks_surveys";
|
||||
case "csv":
|
||||
return "workspace.unify.csv_import";
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping } from "../types";
|
||||
|
||||
export const isConnectorNameValid = (name: string): boolean => name.trim().length > 0;
|
||||
|
||||
export const areAllRequiredFieldsMapped = (mappings: TFieldMapping[]): boolean => {
|
||||
const requiredFieldIds = new Set(
|
||||
FEEDBACK_RECORD_FIELDS.filter((field) => field.required).map((field) => field.id)
|
||||
);
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!requiredFieldIds.has(mapping.targetFieldId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (mapping.sourceFieldId || mapping.staticValue) {
|
||||
requiredFieldIds.delete(mapping.targetFieldId);
|
||||
}
|
||||
}
|
||||
|
||||
return requiredFieldIds.size === 0;
|
||||
};
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileSpreadsheetIcon,
|
||||
MoreVertical,
|
||||
PauseIcon,
|
||||
PlayIcon,
|
||||
SquarePenIcon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/modules/ui/components/dropdown-menu";
|
||||
|
||||
interface ConnectorRowDropdownProps {
|
||||
connector: TConnectorWithMappings;
|
||||
onEdit: () => void;
|
||||
onCsvImport?: () => void;
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function ConnectorRowDropdown({
|
||||
connector,
|
||||
onEdit,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorRowDropdownProps) {
|
||||
const router = useRouter();
|
||||
const { t } = useTranslation();
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const isActive = connector.status === "active";
|
||||
const linkedSurveyId =
|
||||
connector.type === "formbricks_survey" ? connector.formbricksMappings[0]?.surveyId : undefined;
|
||||
|
||||
const handleDelete = async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setIsDeleteDialogOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div // eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
data-testid="connector-row-dropdown">
|
||||
<DropdownMenu open={isDropDownOpen} onOpenChange={setIsDropDownOpen}>
|
||||
<DropdownMenuTrigger className="z-10" asChild>
|
||||
<div className="cursor-pointer rounded-lg border bg-white p-2 hover:bg-slate-50">
|
||||
<span className="sr-only">{t("workspace.surveys.open_options")}</span>
|
||||
<MoreVertical className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="inline-block w-auto min-w-max">
|
||||
<DropdownMenuGroup>
|
||||
{connector.type === "csv" && onCsvImport && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onCsvImport();
|
||||
}}>
|
||||
<FileSpreadsheetIcon className="mr-2 h-4 w-4" />
|
||||
{t("workspace.unify.import_csv_data")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{linkedSurveyId && (
|
||||
<>
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
router.push(`/workspaces/${connector.workspaceId}/surveys/${linkedSurveyId}/summary`);
|
||||
}}>
|
||||
<EyeIcon className="mr-2 h-4 w-4" />
|
||||
{`${t("common.view")} ${t("common.survey")}`}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
onEdit();
|
||||
}}>
|
||||
<SquarePenIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.edit")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
await onDuplicate();
|
||||
}}>
|
||||
<CopyIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.duplicate")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
await onToggleStatus();
|
||||
}}>
|
||||
{isActive ? <PauseIcon className="mr-2 h-4 w-4" /> : <PlayIcon className="mr-2 h-4 w-4" />}
|
||||
{isActive ? t("common.disable") : t("common.enable")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDropDownOpen(false);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}>
|
||||
<TrashIcon className="mr-2 h-4 w-4" />
|
||||
{t("common.delete")}
|
||||
</button>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteDialog
|
||||
deleteWhat={t("workspace.unify.source")}
|
||||
open={isDeleteDialogOpen}
|
||||
setOpen={setIsDeleteDialogOpen}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={isDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert, AlertButton, AlertTitle } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { TConnectorOptionId, getConnectorOptions } from "../utils";
|
||||
|
||||
interface ConnectorTypeSelectorProps {
|
||||
selectedType: TConnectorOptionId | null;
|
||||
onSelectType: (type: TConnectorOptionId) => void;
|
||||
}
|
||||
|
||||
const getOptionClassName = (
|
||||
selectedType: TConnectorOptionId | null,
|
||||
optionId: TConnectorOptionId,
|
||||
disabled: boolean
|
||||
): string => {
|
||||
if (selectedType === optionId) {
|
||||
return "border-brand-dark bg-slate-50";
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60";
|
||||
}
|
||||
|
||||
return "border-slate-200 hover:border-slate-300 hover:bg-slate-50";
|
||||
};
|
||||
|
||||
export function ConnectorTypeSelector({ selectedType, onSelectType }: Readonly<ConnectorTypeSelectorProps>) {
|
||||
const { t } = useTranslation();
|
||||
const connectorOptions = getConnectorOptions(t);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{connectorOptions.map((option) => (
|
||||
<button
|
||||
key={option.id}
|
||||
type="button"
|
||||
disabled={option.disabled}
|
||||
onClick={() => onSelectType(option.id)}
|
||||
className={`flex w-full items-center justify-between rounded-lg border p-3.5 text-left text-sm transition-colors ${getOptionClassName(
|
||||
selectedType,
|
||||
option.id,
|
||||
option.disabled
|
||||
)}`}>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium leading-5 text-slate-900">{option.name}</span>
|
||||
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-slate-500">{option.description}</p>
|
||||
</div>
|
||||
<div
|
||||
className={`ml-3 h-4 w-4 rounded-full border-2 ${
|
||||
selectedType === option.id ? "border-brand-dark bg-brand-dark" : "border-slate-300"
|
||||
}`}>
|
||||
{selectedType === option.id && (
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-white" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Alert variant="outbound" size="small">
|
||||
<AlertTitle>{t("workspace.unify.missing_feedback_source_title")}</AlertTitle>
|
||||
<AlertButton asChild>
|
||||
<Link
|
||||
href="https://app.formbricks.com/s/cmob8tub9s2ndu5010ei4it0g"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-slate-900 hover:underline">
|
||||
{t("workspace.unify.request_feedback_source")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import { toast } from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType, TConnectorWithMappings, THubTargetField } from "@formbricks/types/connector";
|
||||
import { SettingsCard } from "@/app/(app)/workspaces/[workspaceId]/settings/components/SettingsCard";
|
||||
import {
|
||||
createConnectorWithMappingsAction,
|
||||
deleteConnectorAction,
|
||||
duplicateConnectorAction,
|
||||
updateConnectorWithMappingsAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import { WorkspaceConfigNavigation } from "@/modules/workspaces/settings/components/workspace-config-navigation";
|
||||
import { TFieldMapping, TUnifySurvey } from "../types";
|
||||
import { ConnectorsTable } from "./connectors-table";
|
||||
import { CreateConnectorModal } from "./create-connector-modal";
|
||||
import { CsvImportModal } from "./csv-import-modal";
|
||||
import { EditConnectorModal } from "./edit-connector-modal";
|
||||
|
||||
interface ConnectorsSectionProps {
|
||||
workspaceId: string;
|
||||
initialConnectors: TConnectorWithMappings[];
|
||||
initialSurveys: TUnifySurvey[];
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function ConnectorsSection({
|
||||
workspaceId,
|
||||
initialConnectors,
|
||||
initialSurveys,
|
||||
directories,
|
||||
}: Readonly<ConnectorsSectionProps>) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const [editingConnector, setEditingConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const [csvImportConnector, setCsvImportConnector] = useState<TConnectorWithMappings | null>(null);
|
||||
const directoryNames = directories.map((directory) => directory.name).join(", ");
|
||||
const feedbackDirectoryAccessText =
|
||||
directories.length === 1
|
||||
? t("workspace.unify.feedback_sources_directory_access_single", {
|
||||
directoryNames,
|
||||
})
|
||||
: t("workspace.unify.feedback_sources_directory_access_multiple", {
|
||||
directoryNames,
|
||||
});
|
||||
|
||||
const handleCreateConnector = async (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}): Promise<string | undefined> => {
|
||||
const result = await createConnectorWithMappingsAction({
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
type: data.type,
|
||||
feedbackRecordDirectoryId: data.feedbackRecordDirectoryId,
|
||||
},
|
||||
formbricksMappings:
|
||||
data.type === "formbricks_survey" && data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings:
|
||||
data.type !== "formbricks_survey" && data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
staticValue: m.staticValue,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_created_successfully"));
|
||||
router.refresh();
|
||||
return result.data.id;
|
||||
};
|
||||
|
||||
const handleUpdateConnector = async (data: {
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => {
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: data.connectorId,
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: {
|
||||
name: data.name,
|
||||
},
|
||||
formbricksMappings: data.surveyMappings?.length ? data.surveyMappings : undefined,
|
||||
fieldMappings: data.fieldMappings?.length
|
||||
? data.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId || "",
|
||||
targetFieldId: m.targetFieldId as THubTargetField,
|
||||
staticValue: m.staticValue,
|
||||
}))
|
||||
: undefined,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_updated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
|
||||
const result = await deleteConnectorAction({ connectorId, workspaceId: workspaceId });
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_deleted_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
|
||||
const result = await duplicateConnectorAction({
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceId,
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_duplicated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (connector: TConnectorWithMappings): Promise<void> => {
|
||||
const newStatus = connector.status === "active" ? "paused" : "active";
|
||||
const result = await updateConnectorWithMappingsAction({
|
||||
connectorId: connector.id,
|
||||
workspaceId: workspaceId,
|
||||
connectorInput: { status: newStatus },
|
||||
});
|
||||
|
||||
if (!result?.data) {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("workspace.unify.connector_status_updated_successfully"));
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<PageContentWrapper>
|
||||
<PageHeader pageTitle={t("common.workspace_configuration")}>
|
||||
<WorkspaceConfigNavigation activeId="feedback-sources" />
|
||||
</PageHeader>
|
||||
|
||||
<SettingsCard
|
||||
title={t("workspace.unify.feedback_sources")}
|
||||
description={t("workspace.unify.feedback_sources_settings_description")}
|
||||
buttonInfo={{
|
||||
text: t("workspace.unify.add_source"),
|
||||
onClick: () => setIsCreateModalOpen(true),
|
||||
variant: "default",
|
||||
}}>
|
||||
<ConnectorsTable
|
||||
connectors={initialConnectors}
|
||||
onConnectorClick={setEditingConnector}
|
||||
onCsvImport={setCsvImportConnector}
|
||||
onDuplicate={handleDuplicateConnector}
|
||||
onToggleStatus={handleToggleStatus}
|
||||
onDelete={handleDeleteConnector}
|
||||
isLoading={false}
|
||||
/>
|
||||
{directories.length > 0 && (
|
||||
<Alert size="small" className="mt-4">
|
||||
<AlertDescription>{feedbackDirectoryAccessText}</AlertDescription>
|
||||
<AlertButton asChild>
|
||||
<Link href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.manage_directories")}
|
||||
</Link>
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
</SettingsCard>
|
||||
|
||||
<CreateConnectorModal
|
||||
open={isCreateModalOpen}
|
||||
onOpenChange={setIsCreateModalOpen}
|
||||
onCreateConnector={handleCreateConnector}
|
||||
surveys={initialSurveys}
|
||||
workspaceId={workspaceId}
|
||||
directories={directories}
|
||||
showTrigger={false}
|
||||
/>
|
||||
|
||||
<EditConnectorModal
|
||||
connector={editingConnector}
|
||||
open={editingConnector !== null}
|
||||
onOpenChange={(open) => !open && setEditingConnector(null)}
|
||||
onUpdateConnector={handleUpdateConnector}
|
||||
surveys={initialSurveys}
|
||||
onOpenCsvImport={() => {
|
||||
if (editingConnector) {
|
||||
setCsvImportConnector(editingConnector);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{csvImportConnector && (
|
||||
<CsvImportModal
|
||||
open={csvImportConnector !== null}
|
||||
onOpenChange={(open) => !open && setCsvImportConnector(null)}
|
||||
connectorId={csvImportConnector.id}
|
||||
workspaceId={csvImportConnector.workspaceId}
|
||||
onOpenEditConnector={() => {
|
||||
setEditingConnector(csvImportConnector);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PageContentWrapper>
|
||||
);
|
||||
}
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { ConnectorRowDropdown } from "./connector-row-dropdown";
|
||||
|
||||
const RELATIVE_TIME_DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [
|
||||
{ amount: 60, unit: "seconds" },
|
||||
{ amount: 60, unit: "minutes" },
|
||||
{ amount: 24, unit: "hours" },
|
||||
{ amount: 7, unit: "days" },
|
||||
{ amount: 4.345, unit: "weeks" },
|
||||
{ amount: 12, unit: "months" },
|
||||
{ amount: Number.POSITIVE_INFINITY, unit: "years" },
|
||||
];
|
||||
|
||||
function getRelativeTime(date: Date, locale: string) {
|
||||
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
||||
let duration = (date.getTime() - Date.now()) / 1000;
|
||||
|
||||
for (const division of RELATIVE_TIME_DIVISIONS) {
|
||||
if (Math.abs(duration) < division.amount) {
|
||||
return formatter.format(Math.round(duration), division.unit);
|
||||
}
|
||||
duration /= division.amount;
|
||||
}
|
||||
|
||||
return formatter.format(Math.round(duration), "years");
|
||||
}
|
||||
|
||||
interface ConnectorsTableDataRowProps {
|
||||
connector: TConnectorWithMappings;
|
||||
onEdit: () => void;
|
||||
onCsvImport?: () => void;
|
||||
onDuplicate: () => Promise<void>;
|
||||
onToggleStatus: () => Promise<void>;
|
||||
onDelete: () => Promise<void>;
|
||||
}
|
||||
|
||||
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
|
||||
active: "success",
|
||||
paused: "warning",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
export function ConnectorsTableDataRow({
|
||||
connector,
|
||||
onEdit,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: Readonly<ConnectorsTableDataRowProps>) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const handleRowClick = () => {
|
||||
if (connector.type === "csv" && onCsvImport) {
|
||||
onCsvImport();
|
||||
return;
|
||||
}
|
||||
|
||||
onEdit();
|
||||
};
|
||||
|
||||
const getStatusLabel = (s: TConnectorStatus, connectorType: TConnectorType) => {
|
||||
switch (s) {
|
||||
case "active":
|
||||
if (connectorType === "csv") {
|
||||
return t("workspace.unify.status_ready");
|
||||
}
|
||||
return t("workspace.unify.status_live_sync");
|
||||
case "paused":
|
||||
return t("workspace.unify.status_paused");
|
||||
case "error":
|
||||
return t("workspace.unify.status_error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="grid h-12 min-h-12 cursor-pointer grid-cols-12 content-center p-2 text-left transition-colors ease-in-out hover:bg-slate-50"
|
||||
onClick={handleRowClick}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
handleRowClick();
|
||||
}
|
||||
}}>
|
||||
<div
|
||||
className="col-span-1 flex items-center gap-2 pl-4"
|
||||
title={t(getConnectorTypeLabelKey(connector.type))}>
|
||||
{getConnectorIcon(connector.type, "h-4 w-4 text-slate-500")}
|
||||
</div>
|
||||
<div className="col-span-5 flex items-center">
|
||||
<span className="truncate text-sm font-medium text-slate-900">{connector.name}</span>
|
||||
</div>
|
||||
<div className="col-span-1 hidden items-center justify-center sm:flex">
|
||||
<Badge
|
||||
text={getStatusLabel(connector.status, connector.type)}
|
||||
type={STATUS_BADGE_TYPE[connector.status]}
|
||||
size="tiny"
|
||||
/>
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
{getRelativeTime(connector.updatedAt, i18n.language)}
|
||||
</div>
|
||||
<div className="col-span-2 hidden items-center justify-center text-sm text-slate-500 sm:flex">
|
||||
<span className="truncate">{connector.creatorName ?? "—"}</span>
|
||||
</div>
|
||||
<div className="col-span-1 flex items-center justify-end pr-2">
|
||||
<ConnectorRowDropdown
|
||||
connector={connector}
|
||||
onEdit={onEdit}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { ConnectorsTableDataRow } from "./connectors-table-data-row";
|
||||
|
||||
interface ConnectorsTableRowsContainerProps {
|
||||
connectors: TConnectorWithMappings[];
|
||||
onConnectorClick: (connector: TConnectorWithMappings) => void;
|
||||
onCsvImport: (connector: TConnectorWithMappings) => void;
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const ConnectorsTableRowsContainer = ({
|
||||
connectors,
|
||||
onConnectorClick,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
}: ConnectorsTableRowsContainerProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (connectors.length === 0) {
|
||||
return (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.no_sources_connected")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-slate-100">
|
||||
{connectors.map((connector) => (
|
||||
<ConnectorsTableDataRow
|
||||
key={connector.id}
|
||||
connector={connector}
|
||||
onEdit={() => onConnectorClick(connector)}
|
||||
onCsvImport={connector.type === "csv" ? () => onCsvImport(connector) : undefined}
|
||||
onDuplicate={() => onDuplicate(connector)}
|
||||
onToggleStatus={() => onToggleStatus(connector)}
|
||||
onDelete={() => onDelete(connector.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2Icon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { ConnectorsTableRowsContainer } from "./connectors-table-rows-container";
|
||||
|
||||
interface ConnectorsTableProps {
|
||||
connectors: TConnectorWithMappings[];
|
||||
onConnectorClick: (connector: TConnectorWithMappings) => void;
|
||||
onCsvImport: (connector: TConnectorWithMappings) => void;
|
||||
onDuplicate: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onToggleStatus: (connector: TConnectorWithMappings) => Promise<void>;
|
||||
onDelete: (connectorId: string) => Promise<void>;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function ConnectorsTable({
|
||||
connectors,
|
||||
onConnectorClick,
|
||||
onCsvImport,
|
||||
onDuplicate,
|
||||
onToggleStatus,
|
||||
onDelete,
|
||||
isLoading = false,
|
||||
}: Readonly<ConnectorsTableProps>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="grid h-12 grid-cols-12 content-center border-b border-slate-200 text-left text-sm font-semibold text-slate-900">
|
||||
<div className="col-span-1 pl-6">{t("common.type")}</div>
|
||||
<div className="col-span-5">{t("common.name")}</div>
|
||||
<div className="col-span-1 hidden text-center sm:block">{t("common.status")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.updated_at")}</div>
|
||||
<div className="col-span-2 hidden text-center sm:block">{t("workspace.unify.created_by")}</div>
|
||||
<div className="col-span-1" />
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2Icon className="h-6 w-6 animate-spin text-slate-500" />
|
||||
</div>
|
||||
) : (
|
||||
<ConnectorsTableRowsContainer
|
||||
connectors={connectors}
|
||||
onConnectorClick={onConnectorClick}
|
||||
onCsvImport={onCsvImport}
|
||||
onDuplicate={onDuplicate}
|
||||
onToggleStatus={onToggleStatus}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+640
@@ -0,0 +1,640 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Loader2Icon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TConnectorType, UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import {
|
||||
getResponseCountAction,
|
||||
importCsvDataAction,
|
||||
importHistoricalResponsesAction,
|
||||
} from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { Switch } from "@/modules/ui/components/switch";
|
||||
import { TCreateConnectorStep, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import {
|
||||
TConnectorOptionId,
|
||||
TEnumValidationError,
|
||||
parseCSVColumnsToFields,
|
||||
validateEnumMappings,
|
||||
} from "../utils";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { ConnectorTypeSelector } from "./connector-type-selector";
|
||||
import { CsvConnectorUI } from "./csv-connector-ui";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
|
||||
interface CreateConnectorModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
showTrigger?: boolean;
|
||||
onCreateConnector: (data: {
|
||||
name: string;
|
||||
type: TConnectorType;
|
||||
feedbackRecordDirectoryId: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<string | undefined>;
|
||||
surveys: TUnifySurvey[];
|
||||
workspaceId: string;
|
||||
directories: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
const getDialogTitle = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.add_feedback_source");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_and_questions");
|
||||
if (type === "csv") return t("workspace.unify.import_csv_data");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getDialogDescription = (
|
||||
step: TCreateConnectorStep,
|
||||
type: TConnectorOptionId | null,
|
||||
t: (key: string) => string
|
||||
): string => {
|
||||
if (step === "selectType") return t("workspace.unify.select_source_type_description");
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_survey_questions_description");
|
||||
if (type === "csv") return t("workspace.unify.upload_csv_data_description");
|
||||
return t("workspace.unify.configure_mapping");
|
||||
};
|
||||
|
||||
const getNextStepButtonLabel = (type: TConnectorOptionId | null, t: (key: string) => string): string => {
|
||||
if (type === "formbricks_survey") return t("workspace.unify.select_questions");
|
||||
if (type === "csv") return t("workspace.unify.configure_import");
|
||||
if (type === "api_ingestion") return t("workspace.unify.api_ingestion_manage_api_keys");
|
||||
if (type === "feedback_record_mcp") return t("common.learn_more");
|
||||
return t("workspace.unify.create_mapping");
|
||||
};
|
||||
|
||||
const ZFormbricksConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
type TFormbricksConnectorForm = z.infer<typeof ZFormbricksConnectorForm>;
|
||||
|
||||
const getSelectableQuestionIds = (survey: TUnifySurvey): string[] =>
|
||||
survey.elements
|
||||
.filter((element) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(element.type))
|
||||
.map((element) => element.id);
|
||||
|
||||
export const CreateConnectorModal = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
showTrigger = true,
|
||||
onCreateConnector,
|
||||
surveys,
|
||||
workspaceId,
|
||||
directories,
|
||||
}: CreateConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const defaultConnectorName = useMemo<Record<TConnectorType, string>>(
|
||||
() => ({
|
||||
formbricks_survey: t("workspace.unify.default_connector_name_formbricks"),
|
||||
csv: t("workspace.unify.default_connector_name_csv"),
|
||||
}),
|
||||
[t]
|
||||
);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
|
||||
const [selectedType, setSelectedType] = useState<TConnectorOptionId | null>(null);
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
|
||||
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [selectedDirectoryId, setSelectedDirectoryId] = useState<string | null>(directories[0]?.id ?? null);
|
||||
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
|
||||
|
||||
const selectedSurvey = useMemo(
|
||||
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
|
||||
[surveys, selectedSurveyId]
|
||||
);
|
||||
|
||||
const selectedSurveyResponseCount =
|
||||
selectedSurveyId && responseCountBySurvey[selectedSurveyId] !== undefined
|
||||
? responseCountBySurvey[selectedSurveyId]
|
||||
: null;
|
||||
|
||||
const fetchResponseCount = useCallback(
|
||||
async (surveyId: string) => {
|
||||
if (responseCountBySurvey[surveyId] !== undefined) return;
|
||||
try {
|
||||
const result = await getResponseCountAction({ surveyId, workspaceId });
|
||||
if (result?.data !== undefined) {
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
|
||||
}
|
||||
} catch {
|
||||
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
|
||||
}
|
||||
},
|
||||
[responseCountBySurvey, workspaceId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSurveyId && currentStep === "mapping" && selectedType === "formbricks_survey") {
|
||||
fetchResponseCount(selectedSurveyId);
|
||||
}
|
||||
}, [currentStep, fetchResponseCount, selectedSurveyId, selectedType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep !== "mapping" || selectedType !== "formbricks_survey" || !selectedSurveyId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const survey = surveys.find((item) => item.id === selectedSurveyId);
|
||||
const supportedElementIds = survey ? getSelectableQuestionIds(survey) : [];
|
||||
|
||||
formbricksForm.setValue("selectedQuestionIds", supportedElementIds, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
formbricksForm.setValue("importHistorical", true, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}, [currentStep, formbricksForm, selectedSurveyId, selectedType, surveys]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCurrentStep("selectType");
|
||||
setSelectedType(null);
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setCsvParsedData([]);
|
||||
setEnumValidationErrors([]);
|
||||
setResponseCountBySurvey({});
|
||||
setCsvConnectorName("");
|
||||
setIsImporting(false);
|
||||
setIsCreating(false);
|
||||
setSelectedDirectoryId(directories[0]?.id ?? null);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (isImporting) return;
|
||||
if (!newOpen) resetForm();
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleNextStep = () => {
|
||||
if (currentStep !== "selectType" || !selectedType) return;
|
||||
|
||||
if (selectedType === "api_ingestion") {
|
||||
handleOpenChange(false);
|
||||
router.push(`/workspaces/${workspaceId}/settings/api-keys`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "feedback_record_mcp") {
|
||||
window.open("https://formbricks.com/docs", "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType === "formbricks_survey") {
|
||||
formbricksForm.reset({
|
||||
sourceName: defaultConnectorName.formbricks_survey,
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedType === "csv") {
|
||||
setCsvConnectorName(defaultConnectorName.csv);
|
||||
}
|
||||
|
||||
setCurrentStep("mapping");
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep === "mapping") {
|
||||
setCurrentStep("selectType");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleHistoricalImport = async (connectorId: string, surveyId: string) => {
|
||||
const responseCount = responseCountBySurvey[surveyId] ?? 0;
|
||||
if (responseCount <= 0) return;
|
||||
setIsImporting(true);
|
||||
const importResult = await importHistoricalResponsesAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
surveyId,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.historical_import_complete", {
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCsvImport = async (connectorId: string) => {
|
||||
setIsImporting(true);
|
||||
const importResult = await importCsvDataAction({
|
||||
connectorId,
|
||||
workspaceId,
|
||||
csvData: csvParsedData,
|
||||
});
|
||||
setIsImporting(false);
|
||||
|
||||
if (importResult?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.csv_import_complete", {
|
||||
successes: importResult.data.successes,
|
||||
failures: importResult.data.failures,
|
||||
skipped: importResult.data.skipped,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(importResult));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
|
||||
const isSelected = currentSelection.includes(questionId);
|
||||
const nextSelection = isSelected
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleCreateFormbricksConnector = async (values: TFormbricksConnectorForm) => {
|
||||
if (!selectedDirectoryId) return;
|
||||
setIsCreating(true);
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: values.sourceName.trim(),
|
||||
type: "formbricks_survey",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
});
|
||||
|
||||
if (connectorId && values.importHistorical) {
|
||||
await handleHistoricalImport(connectorId, values.surveyId);
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const handleCreateCsvConnector = async () => {
|
||||
if (!selectedDirectoryId || !isConnectorNameValid(csvConnectorName)) return;
|
||||
if (csvParsedData.length > 0) {
|
||||
const errors = validateEnumMappings(mappings, csvParsedData);
|
||||
if (errors.length > 0) {
|
||||
setEnumValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
setEnumValidationErrors([]);
|
||||
}
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const connectorId = await onCreateConnector({
|
||||
name: csvConnectorName.trim(),
|
||||
type: "csv",
|
||||
feedbackRecordDirectoryId: selectedDirectoryId,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
|
||||
if (connectorId && csvParsedData.length > 0) {
|
||||
await handleCsvImport(connectorId);
|
||||
}
|
||||
|
||||
setIsCreating(false);
|
||||
resetForm();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
|
||||
const areCsvRequiredFieldsMapped = areAllRequiredFieldsMapped(mappings);
|
||||
|
||||
const handleLoadSourceFields = () => {
|
||||
if (selectedType === "csv") {
|
||||
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
|
||||
setSourceFields(fields);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{showTrigger && (
|
||||
<Button onClick={() => onOpenChange(true)} size="sm">
|
||||
{t("workspace.unify.add_source")}
|
||||
<PlusIcon className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
{isImporting && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center rounded-lg bg-white/80">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2Icon className="h-8 w-8 animate-spin text-slate-500" />
|
||||
<p className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.importing_historical_data")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogHeader>
|
||||
<DialogTitle>{getDialogTitle(currentStep, selectedType, t)}</DialogTitle>
|
||||
<DialogDescription>{getDialogDescription(currentStep, selectedType, t)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4">
|
||||
{currentStep === "selectType" && (
|
||||
<ConnectorTypeSelector selectedType={selectedType} onSelectType={setSelectedType} />
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "formbricks_survey" && (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{surveys.map((survey) => (
|
||||
<SelectItem key={survey.id} value={survey.id}>
|
||||
{survey.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedSurveyResponseCount !== null && selectedSurveyResponseCount > 0 && (
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="importHistorical"
|
||||
render={({ field }) => (
|
||||
<FormItem className="rounded-md border border-slate-200 p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<FormLabel>{t("workspace.unify.import_historical_responses")}</FormLabel>
|
||||
<p className="text-sm text-slate-500">
|
||||
{t("workspace.unify.import_historical_responses_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</FormProvider>
|
||||
)}
|
||||
|
||||
{currentStep === "mapping" && selectedType === "csv" && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="connectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="connectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{directories.length === 0 && (
|
||||
<NoFeedbackRecordDirectoryAlert workspaceId={workspaceId} t={t} />
|
||||
)}
|
||||
|
||||
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<CsvConnectorUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={(m) => {
|
||||
setMappings(m);
|
||||
setEnumValidationErrors([]);
|
||||
}}
|
||||
onSourceFieldsChange={setSourceFields}
|
||||
onLoadSampleCSV={handleLoadSourceFields}
|
||||
onParsedDataChange={setCsvParsedData}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enumValidationErrors.length > 0 && (
|
||||
<Alert variant="error" size="small">
|
||||
{enumValidationErrors.map((err) => {
|
||||
const uniqueValues = [...new Set(err.invalidEntries.map((e) => e.value))];
|
||||
const rowNumbers = err.invalidEntries.slice(0, 5).map((e) => e.row);
|
||||
return (
|
||||
<div key={err.targetFieldName} className="text-xs">
|
||||
<p className="font-medium">
|
||||
{t("workspace.unify.invalid_enum_values", {
|
||||
field: err.targetFieldName,
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("workspace.unify.invalid_values_found", {
|
||||
values: uniqueValues.join(", "),
|
||||
rows: rowNumbers.join(", "),
|
||||
extra: err.invalidEntries.length > 5 ? `+${err.invalidEntries.length - 5}` : "",
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-slate-500">
|
||||
{t("workspace.unify.allowed_values", {
|
||||
values: err.allowedValues.join(", "),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{currentStep === "mapping" && (
|
||||
<Button variant="outline" onClick={handleBack} disabled={isCreating || isImporting}>
|
||||
{t("common.back")}
|
||||
</Button>
|
||||
)}
|
||||
{currentStep === "selectType" ? (
|
||||
<Button onClick={handleNextStep} disabled={!selectedType}>
|
||||
{getNextStepButtonLabel(selectedType, t)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={
|
||||
selectedType === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleCreateFormbricksConnector)()
|
||||
: handleCreateCsvConnector
|
||||
}
|
||||
disabled={
|
||||
isCreating ||
|
||||
isImporting ||
|
||||
!selectedDirectoryId ||
|
||||
(selectedType === "formbricks_survey"
|
||||
? !isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
: !isConnectorNameValid(csvConnectorName) || !isCsvValid || !areCsvRequiredFieldsMapped)
|
||||
}>
|
||||
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t("workspace.unify.setup_connection")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface NoFeedbackRecordDirectoryAlertProps {
|
||||
workspaceId: string;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const NoFeedbackRecordDirectoryAlert = ({ workspaceId, t }: NoFeedbackRecordDirectoryAlertProps) => {
|
||||
return (
|
||||
<Alert variant="error" size="small">
|
||||
<div>
|
||||
<p>{t("workspace.unify.no_feedback_record_directory_available")}</p>
|
||||
<a
|
||||
className="mt-1 inline-block font-medium underline"
|
||||
href={`/workspaces/${workspaceId}/settings/feedback-record-directories`}>
|
||||
{t("workspace.unify.go_to_feedback_record_directories")}
|
||||
</a>
|
||||
</div>
|
||||
</Alert>
|
||||
);
|
||||
};
|
||||
+220
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { TFieldMapping, TSourceField, createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface CsvConnectorUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
onSourceFieldsChange: (fields: TSourceField[]) => void;
|
||||
onLoadSampleCSV: () => void;
|
||||
onParsedDataChange?: (data: Record<string, string>[]) => void;
|
||||
}
|
||||
|
||||
export function CsvConnectorUI({
|
||||
sourceFields,
|
||||
mappings,
|
||||
onMappingsChange,
|
||||
onSourceFieldsChange,
|
||||
onLoadSampleCSV,
|
||||
onParsedDataChange,
|
||||
}: CsvConnectorUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [csvPreview, setCsvPreview] = useState<string[][]>([]);
|
||||
const [showMapping, setShowMapping] = useState(false);
|
||||
const [csvError, setCsvError] = useState("");
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
const validateCSVFileResult = validateCsvFile(file, t);
|
||||
|
||||
if (!validateCSVFileResult.valid) {
|
||||
setCsvError(validateCSVFileResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const csv = e.target?.result as string;
|
||||
|
||||
try {
|
||||
const records = parse(csv, { columns: true, skip_empty_lines: true });
|
||||
|
||||
const result = createFeedbackCSVDataSchema(t).safeParse(records);
|
||||
if (!result.success) {
|
||||
setCsvError(result.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
const validRecords = result.data;
|
||||
const headers = Object.keys(validRecords[0]);
|
||||
|
||||
const preview: string[][] = [
|
||||
headers,
|
||||
...validRecords.slice(0, 5).map((row) => headers.map((h) => row[h] ?? "")),
|
||||
];
|
||||
setCsvFile(file);
|
||||
setCsvPreview(preview);
|
||||
|
||||
const fields: TSourceField[] = headers.map((header) => ({
|
||||
id: header,
|
||||
name: header,
|
||||
type: "string",
|
||||
sampleValue: validRecords[0][header] ?? "",
|
||||
}));
|
||||
onSourceFieldsChange(fields);
|
||||
onParsedDataChange?.(validRecords);
|
||||
setShowMapping(true);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
|
||||
setCsvError(message);
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) {
|
||||
processCSVFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSample = () => {
|
||||
onLoadSampleCSV();
|
||||
setShowMapping(true);
|
||||
};
|
||||
|
||||
if (showMapping && sourceFields.length > 0) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{csvFile && (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${csvPreview.length - 1} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCsvFile(null);
|
||||
setCsvPreview([]);
|
||||
setCsvError("");
|
||||
setShowMapping(false);
|
||||
onSourceFieldsChange([]);
|
||||
onParsedDataChange?.([]);
|
||||
}}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{csvPreview.length > 0 && (
|
||||
<div className="overflow-hidden rounded-lg border border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
{csvPreview[0]?.map((header, i) => (
|
||||
<th key={i} className="px-3 py-2 text-left font-medium text-slate-700">
|
||||
{header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{csvPreview.slice(1, 4).map((row, rowIndex) => (
|
||||
<tr key={rowIndex} className="border-t border-slate-100">
|
||||
{row.map((cell, cellIndex) => (
|
||||
<td key={cellIndex} className="px-3 py-2 text-slate-600">
|
||||
{cell || <span className="text-slate-300">—</span>}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{csvPreview.length > 4 && (
|
||||
<div className="border-t border-slate-100 bg-slate-50 px-3 py-1.5 text-center text-xs text-slate-500">
|
||||
{t("workspace.unify.showing_rows", { count: csvPreview.length - 1 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={onMappingsChange}
|
||||
connectorType="csv"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
{csvError}
|
||||
</Alert>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">{t("workspace.unify.upload_csv_file")}</h4>
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-file-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">{t("workspace.unify.click_to_upload")}</span>{" "}
|
||||
{t("workspace.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.csv_files_only")}</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-file-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<Button variant="secondary" size="sm" onClick={handleLoadSample}>
|
||||
{t("workspace.unify.load_sample_csv")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { parse } from "csv-parse/sync";
|
||||
import { ArrowUpFromLineIcon, Loader2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { importCsvDataAction } from "@/lib/connector/actions";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert } from "@/modules/ui/components/alert";
|
||||
import { Badge } from "@/modules/ui/components/badge";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import { createFeedbackCSVDataSchema } from "../types";
|
||||
import { validateCsvFile } from "../utils";
|
||||
|
||||
interface CsvImportModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
onOpenEditConnector?: () => void;
|
||||
}
|
||||
|
||||
export function CsvImportModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
connectorId,
|
||||
workspaceId,
|
||||
onOpenEditConnector,
|
||||
}: CsvImportModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [csvFile, setCsvFile] = useState<File | null>(null);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [parsedData, setParsedData] = useState<Record<string, string>[]>([]);
|
||||
const [csvError, setCsvError] = useState("");
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
|
||||
const processCSVFile = (file: File) => {
|
||||
setCsvError("");
|
||||
|
||||
const validateCSVFileResult = validateCsvFile(file, t);
|
||||
|
||||
if (!validateCSVFileResult.valid) {
|
||||
setCsvError(validateCSVFileResult.error);
|
||||
return;
|
||||
}
|
||||
|
||||
file
|
||||
.text()
|
||||
.then((csv) => {
|
||||
const records = parse(csv, { columns: true, skip_empty_lines: true });
|
||||
const result = createFeedbackCSVDataSchema(t).safeParse(records);
|
||||
|
||||
if (!result.success) {
|
||||
setCsvError(result.error.issues[0].message);
|
||||
return;
|
||||
}
|
||||
|
||||
setCsvFile(file);
|
||||
setParsedData(result.data);
|
||||
setRowCount(result.data.length);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : t("common.failed_to_parse_csv");
|
||||
setCsvError(message);
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target?.files?.[0];
|
||||
if (file) processCSVFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLLabelElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file) processCSVFile(file);
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (parsedData.length === 0) return;
|
||||
|
||||
setIsImporting(true);
|
||||
const result = await importCsvDataAction({ connectorId, workspaceId, csvData: parsedData });
|
||||
setIsImporting(false);
|
||||
|
||||
if (result?.data) {
|
||||
toast.success(
|
||||
t("workspace.unify.csv_import_complete", {
|
||||
successes: result.data.successes,
|
||||
failures: result.data.failures,
|
||||
skipped: result.data.skipped,
|
||||
})
|
||||
);
|
||||
setCsvFile(null);
|
||||
setParsedData([]);
|
||||
setRowCount(0);
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
toast.error(getFormattedErrorMessage(result));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setCsvFile(null);
|
||||
setParsedData([]);
|
||||
setRowCount(0);
|
||||
setCsvError("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.unify.import_csv_data")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.unify.upload_csv_data_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Alert variant="info" size="small">
|
||||
{t("workspace.unify.csv_import_duplicate_warning")}
|
||||
</Alert>
|
||||
|
||||
{csvError && (
|
||||
<Alert variant="error" size="small">
|
||||
{csvError}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{csvFile ? (
|
||||
<div className="flex items-center justify-between rounded-lg border border-slate-200 bg-slate-50 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-slate-800">{csvFile.name}</span>
|
||||
<Badge text={`${rowCount} rows`} type="gray" size="tiny" />
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleClear} disabled={isImporting}>
|
||||
{t("workspace.unify.change_file")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border-2 border-dashed border-slate-300 bg-slate-50 p-6">
|
||||
<label
|
||||
htmlFor="csv-import-upload"
|
||||
className="flex cursor-pointer flex-col items-center justify-center"
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}>
|
||||
<ArrowUpFromLineIcon className="h-8 w-8 text-slate-400" />
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
<span className="font-semibold">{t("workspace.unify.click_to_upload")}</span>{" "}
|
||||
{t("workspace.unify.or_drag_and_drop")}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-slate-400">{t("workspace.unify.csv_files_only")}</p>
|
||||
<input
|
||||
type="file"
|
||||
id="csv-import-upload"
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
onChange={handleFileUpload}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{onOpenEditConnector && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
onOpenEditConnector();
|
||||
}}>
|
||||
{t("workspace.unify.edit_csv_mapping")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleImport} disabled={parsedData.length === 0 || isImporting}>
|
||||
{isImporting ? (
|
||||
<>
|
||||
<Loader2Icon className="mr-2 h-4 w-4 animate-spin" />
|
||||
{t("workspace.unify.importing_data")}
|
||||
</>
|
||||
) : (
|
||||
t("workspace.unify.import_rows", { count: rowCount })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
+374
@@ -0,0 +1,374 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/modules/ui/components/dialog";
|
||||
import {
|
||||
FormControl,
|
||||
FormError,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormProvider,
|
||||
} from "@/modules/ui/components/form";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { SAMPLE_CSV_COLUMNS, TFieldMapping, TSourceField, TUnifySurvey } from "../types";
|
||||
import { parseCSVColumnsToFields } from "../utils";
|
||||
import { getConnectorIcon, getConnectorTypeLabelKey } from "./connector-display";
|
||||
import { areAllRequiredFieldsMapped, isConnectorNameValid } from "./connector-form-utils";
|
||||
import { FormbricksQuestionList } from "./formbricks-question-list";
|
||||
import { MappingUI } from "./mapping-ui";
|
||||
|
||||
interface EditConnectorModalProps {
|
||||
connector: TConnectorWithMappings | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpdateConnector: (data: {
|
||||
connectorId: string;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
surveyMappings?: { surveyId: string; elementIds: string[] }[];
|
||||
fieldMappings?: TFieldMapping[];
|
||||
}) => Promise<void>;
|
||||
surveys: TUnifySurvey[];
|
||||
onOpenCsvImport?: () => void;
|
||||
}
|
||||
|
||||
const ZFormbricksEditConnectorForm = z.object({
|
||||
sourceName: z.string().trim().min(1),
|
||||
surveyId: z.string().min(1),
|
||||
selectedQuestionIds: z.array(z.string()).min(1),
|
||||
importHistorical: z.boolean(),
|
||||
});
|
||||
|
||||
type TFormbricksEditConnectorForm = z.infer<typeof ZFormbricksEditConnectorForm>;
|
||||
|
||||
export const EditConnectorModal = ({
|
||||
connector,
|
||||
open,
|
||||
onOpenChange,
|
||||
onUpdateConnector,
|
||||
surveys,
|
||||
onOpenCsvImport,
|
||||
}: EditConnectorModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [csvConnectorName, setCsvConnectorName] = useState("");
|
||||
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
|
||||
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const formbricksForm = useForm<TFormbricksEditConnectorForm>({
|
||||
resolver: zodResolver(ZFormbricksEditConnectorForm),
|
||||
defaultValues: {
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
},
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const formbricksValues = formbricksForm.watch();
|
||||
const selectedSurveyId = formbricksValues.surveyId;
|
||||
const selectedQuestionIds = formbricksValues.selectedQuestionIds ?? [];
|
||||
const selectedSurvey = useMemo(
|
||||
() => surveys.find((survey) => survey.id === selectedSurveyId) ?? null,
|
||||
[surveys, selectedSurveyId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (connector) {
|
||||
if (connector.type === "formbricks_survey") {
|
||||
const mappedSurveyId = connector.formbricksMappings[0]?.surveyId ?? "";
|
||||
const mappedQuestionIds = connector.formbricksMappings
|
||||
.filter((mapping) => mapping.surveyId === mappedSurveyId)
|
||||
.map((mapping) => mapping.elementId);
|
||||
|
||||
formbricksForm.reset({
|
||||
sourceName: connector.name,
|
||||
surveyId: mappedSurveyId,
|
||||
selectedQuestionIds: mappedQuestionIds,
|
||||
importHistorical: true,
|
||||
});
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
} else if (connector.type === "csv") {
|
||||
setCsvConnectorName(connector.name);
|
||||
const columnsFromMappings = [
|
||||
...new Set(connector.fieldMappings.map((m) => m.sourceFieldId).filter(Boolean)),
|
||||
];
|
||||
setSourceFields(
|
||||
columnsFromMappings.length > 0
|
||||
? parseCSVColumnsToFields(columnsFromMappings.join(","))
|
||||
: parseCSVColumnsToFields(SAMPLE_CSV_COLUMNS)
|
||||
);
|
||||
setMappings(
|
||||
connector.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
}))
|
||||
);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
} else {
|
||||
setCsvConnectorName("");
|
||||
setSourceFields([]);
|
||||
setMappings([]);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [connector, formbricksForm]);
|
||||
|
||||
const resetForm = () => {
|
||||
setCsvConnectorName("");
|
||||
setMappings([]);
|
||||
setSourceFields([]);
|
||||
formbricksForm.reset({
|
||||
sourceName: "",
|
||||
surveyId: "",
|
||||
selectedQuestionIds: [],
|
||||
importHistorical: true,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
};
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
resetForm();
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
const handleUpdateFormbricksConnector = async (values: TFormbricksEditConnectorForm) => {
|
||||
if (connector?.type !== "formbricks_survey") return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: values.sourceName.trim(),
|
||||
surveyMappings: [{ surveyId: values.surveyId, elementIds: values.selectedQuestionIds }],
|
||||
fieldMappings: undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleUpdateCsvConnector = async () => {
|
||||
if (connector?.type !== "csv" || !isConnectorNameValid(csvConnectorName)) return;
|
||||
setIsUpdating(true);
|
||||
await onUpdateConnector({
|
||||
connectorId: connector.id,
|
||||
workspaceId: connector.workspaceId,
|
||||
name: csvConnectorName.trim(),
|
||||
surveyMappings: undefined,
|
||||
fieldMappings: mappings.length > 0 ? mappings : undefined,
|
||||
});
|
||||
setIsUpdating(false);
|
||||
handleOpenChange(false);
|
||||
};
|
||||
|
||||
const handleFormbricksQuestionToggle = (questionId: string) => {
|
||||
const currentSelection = formbricksForm.getValues("selectedQuestionIds");
|
||||
const isSelected = currentSelection.includes(questionId);
|
||||
const nextSelection = isSelected
|
||||
? currentSelection.filter((id) => id !== questionId)
|
||||
: [...currentSelection, questionId];
|
||||
formbricksForm.setValue("selectedQuestionIds", nextSelection, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
};
|
||||
|
||||
const saveChangesDisabled = useMemo(() => {
|
||||
if (!connector) return true;
|
||||
if (isUpdating) return true;
|
||||
|
||||
if (connector.type === "formbricks_survey") {
|
||||
return (
|
||||
!isConnectorNameValid(formbricksValues.sourceName ?? "") ||
|
||||
!formbricksValues.surveyId ||
|
||||
!formbricksValues.selectedQuestionIds?.length
|
||||
);
|
||||
}
|
||||
|
||||
if (connector.type === "csv") {
|
||||
return !isConnectorNameValid(csvConnectorName) || !areAllRequiredFieldsMapped(mappings);
|
||||
}
|
||||
|
||||
return true;
|
||||
}, [connector, csvConnectorName, formbricksValues, isUpdating, mappings]);
|
||||
|
||||
if (!connector) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("workspace.unify.edit_source_connection")}</DialogTitle>
|
||||
<DialogDescription>{t("workspace.unify.update_mapping_description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{connector.type === "formbricks_survey" ? (
|
||||
<FormProvider {...formbricksForm}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="sourceName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.source_name")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="surveyId"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_survey")}</FormLabel>
|
||||
<FormControl>
|
||||
<Select value={field.value} onValueChange={field.onChange} disabled>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("workspace.unify.select_survey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{selectedSurvey && (
|
||||
<SelectItem key={selectedSurvey.id} value={selectedSurvey.id}>
|
||||
{selectedSurvey.name}
|
||||
</SelectItem>
|
||||
)}
|
||||
{!selectedSurvey && field.value && (
|
||||
<SelectItem value={field.value}>{field.value}</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={formbricksForm.control}
|
||||
name="selectedQuestionIds"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("workspace.unify.select_questions")}</FormLabel>
|
||||
<FormControl>
|
||||
<div>
|
||||
<FormbricksQuestionList
|
||||
survey={selectedSurvey}
|
||||
selectedQuestionIds={selectedQuestionIds}
|
||||
onQuestionToggle={handleFormbricksQuestionToggle}
|
||||
/>
|
||||
</div>
|
||||
</FormControl>
|
||||
<FormError />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</FormProvider>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
|
||||
{getConnectorIcon(connector.type, "h-5 w-5 text-slate-500")}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-900">
|
||||
{t(getConnectorTypeLabelKey(connector.type))}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("workspace.unify.source_type_cannot_be_changed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="editConnectorName" className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.source_name")}
|
||||
</label>
|
||||
<Input
|
||||
id="editConnectorName"
|
||||
value={csvConnectorName}
|
||||
onChange={(event) => setCsvConnectorName(event.target.value)}
|
||||
placeholder={t("workspace.unify.enter_name_for_source")}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 p-4">
|
||||
<MappingUI
|
||||
sourceFields={sourceFields}
|
||||
mappings={mappings}
|
||||
onMappingsChange={setMappings}
|
||||
connectorType={connector.type}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{connector.type === "csv" && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
handleOpenChange(false);
|
||||
onOpenCsvImport?.();
|
||||
}}>
|
||||
{t("workspace.unify.import_feedback")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={
|
||||
connector.type === "formbricks_survey"
|
||||
? () => void formbricksForm.handleSubmit(handleUpdateFormbricksConnector)()
|
||||
: handleUpdateCsvConnector
|
||||
}
|
||||
disabled={saveChangesDisabled}>
|
||||
{t("workspace.unify.save_changes")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
|
||||
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
|
||||
import { Checkbox } from "@/modules/ui/components/checkbox";
|
||||
import { Label } from "@/modules/ui/components/label";
|
||||
import { TUnifySurvey } from "../types";
|
||||
|
||||
interface FormbricksQuestionListProps {
|
||||
survey: TUnifySurvey | null;
|
||||
selectedQuestionIds: string[];
|
||||
onQuestionToggle: (questionId: string) => void;
|
||||
}
|
||||
|
||||
const isUnsupportedElementType = (type: string): boolean =>
|
||||
(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(type);
|
||||
|
||||
export const FormbricksQuestionList = ({
|
||||
survey,
|
||||
selectedQuestionIds,
|
||||
onQuestionToggle,
|
||||
}: Readonly<FormbricksQuestionListProps>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!survey) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.select_a_survey_to_see_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (survey.elements.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed border-slate-300 p-3">
|
||||
<p className="text-sm text-slate-500">{t("workspace.unify.survey_has_no_questions")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-h-64 space-y-2 overflow-y-auto rounded-md border border-slate-200 p-3">
|
||||
{survey.elements.map((element) => {
|
||||
const unsupported = isUnsupportedElementType(element.type);
|
||||
const isChecked = selectedQuestionIds.includes(element.id);
|
||||
const elementTypeLabel = getTSurveyElementTypeEnumName(element.type, t) ?? element.type;
|
||||
const inputId = `connector-question-${element.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={element.id}
|
||||
className={`flex items-start gap-3 rounded-md border border-slate-100 p-2 ${
|
||||
unsupported ? "opacity-60" : ""
|
||||
}`}>
|
||||
<Checkbox
|
||||
id={inputId}
|
||||
checked={!unsupported && isChecked}
|
||||
disabled={unsupported}
|
||||
onCheckedChange={() => {
|
||||
if (!unsupported) {
|
||||
onQuestionToggle(element.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor={inputId} className={unsupported ? "cursor-not-allowed" : "cursor-pointer"}>
|
||||
{element.headline}
|
||||
</Label>
|
||||
<p className="text-xs text-slate-500">{elementTypeLabel}</p>
|
||||
{unsupported && (
|
||||
<p className="text-xs text-slate-500">{t("workspace.unify.question_type_not_supported")}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+358
@@ -0,0 +1,358 @@
|
||||
"use client";
|
||||
|
||||
import { useDraggable, useDroppable } from "@dnd-kit/core";
|
||||
import { ChevronDownIcon, GripVerticalIcon, PencilIcon, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Input } from "@/modules/ui/components/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/modules/ui/components/select";
|
||||
import { cn } from "@/modules/ui/lib/utils";
|
||||
import { TFieldMapping, TSourceField, TTargetField } from "../types";
|
||||
|
||||
interface DraggableSourceFieldProps {
|
||||
field: TSourceField;
|
||||
isMapped: boolean;
|
||||
}
|
||||
|
||||
export const DraggableSourceField = ({ field, isMapped }: DraggableSourceFieldProps) => {
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const style = transform
|
||||
? {
|
||||
transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
className={`flex cursor-grab items-center gap-2 rounded-md border p-2 text-sm transition-colors ${
|
||||
isDragging
|
||||
? "border-brand-dark bg-slate-100 opacity-50"
|
||||
: isMapped
|
||||
? "border-green-300 bg-green-50 text-green-800"
|
||||
: "border-slate-200 bg-white hover:border-slate-300"
|
||||
}`}>
|
||||
<GripVerticalIcon className="h-4 w-4 text-slate-400" />
|
||||
<div className="flex-1 truncate">
|
||||
<span className="font-medium">{field.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({field.type})</span>
|
||||
</div>
|
||||
{field.sampleValue && (
|
||||
<span className="max-w-24 truncate text-xs text-slate-400">{field.sampleValue}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const getMappingStateClass = (isActive: boolean, hasMapping: unknown): string => {
|
||||
if (isActive) return "border-brand-dark bg-slate-100";
|
||||
if (hasMapping) return "border-green-300 bg-green-50";
|
||||
return "border-dashed border-slate-300 bg-slate-50";
|
||||
};
|
||||
|
||||
interface RemoveMappingButtonProps {
|
||||
onClick: () => void;
|
||||
variant: "green" | "blue";
|
||||
}
|
||||
|
||||
const RemoveMappingButton = ({ onClick, variant }: RemoveMappingButtonProps) => {
|
||||
const colorClass = variant === "green" ? "hover:bg-green-100" : "hover:bg-blue-100";
|
||||
const iconClass = variant === "green" ? "text-green-600" : "text-blue-600";
|
||||
return (
|
||||
<button type="button" onClick={onClick} className={`ml-1 rounded p-0.5 ${colorClass}`}>
|
||||
<XIcon className={`h-3 w-3 ${iconClass}`} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
interface EnumTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const EnumTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: EnumTargetFieldContentProps) => {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.enum")}</span>
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
) : (
|
||||
<Select value={mapping?.staticValue || ""} onValueChange={onStaticValueChange}>
|
||||
<SelectTrigger className="h-8 w-full bg-white">
|
||||
<SelectValue placeholder={t("workspace.unify.select_a_value")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{field.enumValues?.map((value) => (
|
||||
<SelectItem key={value} value={value}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface StringTargetFieldContentProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
hasMapping: unknown;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const StringTargetFieldContent = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
hasMapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
t,
|
||||
}: StringTargetFieldContentProps) => {
|
||||
const [isEditingStatic, setIsEditingStatic] = useState(false);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= “{mapping.staticValue}”
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingStatic && !hasMapping && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={
|
||||
field.exampleStaticValues
|
||||
? `e.g., ${field.exampleStaticValues[0]}`
|
||||
: t("workspace.unify.enter_value")
|
||||
}
|
||||
className="h-7 text-xs"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setCustomValue("");
|
||||
setIsEditingStatic(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (customValue.trim()) {
|
||||
onStaticValueChange(customValue.trim());
|
||||
setCustomValue("");
|
||||
}
|
||||
setIsEditingStatic(false);
|
||||
}}
|
||||
className="rounded p-1 text-slate-500 hover:bg-slate-200">
|
||||
<ChevronDownIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && !isEditingStatic && (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_field_or")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsEditingStatic(true)}
|
||||
className="flex items-center gap-1 rounded px-1 py-0.5 text-xs text-slate-500 hover:bg-slate-200">
|
||||
<PencilIcon className="h-3 w-3" />
|
||||
{t("workspace.unify.set_value")}
|
||||
</button>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.slice(0, 3).map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{val}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface DroppableTargetFieldProps {
|
||||
field: TTargetField;
|
||||
mappedSourceField: TSourceField | null;
|
||||
mapping: TFieldMapping | null;
|
||||
onRemoveMapping: () => void;
|
||||
onStaticValueChange: (value: string) => void;
|
||||
isOver?: boolean;
|
||||
}
|
||||
|
||||
export const DroppableTargetField = ({
|
||||
field,
|
||||
mappedSourceField,
|
||||
mapping,
|
||||
onRemoveMapping,
|
||||
onStaticValueChange,
|
||||
isOver,
|
||||
}: DroppableTargetFieldProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { setNodeRef, isOver: isOverCurrent } = useDroppable({
|
||||
id: field.id,
|
||||
data: field,
|
||||
});
|
||||
|
||||
const isActive = isOver || isOverCurrent;
|
||||
const hasMapping = mappedSourceField || mapping?.staticValue;
|
||||
const containerClass = cn(
|
||||
"flex items-center gap-2 rounded-md border p-2 text-sm transition-colors",
|
||||
getMappingStateClass(!!isActive, hasMapping)
|
||||
);
|
||||
|
||||
if (field.type === "enum" && field.enumValues) {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<EnumTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === "string") {
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<StringTargetFieldContent
|
||||
field={field}
|
||||
mappedSourceField={mappedSourceField}
|
||||
mapping={mapping}
|
||||
hasMapping={hasMapping}
|
||||
onRemoveMapping={onRemoveMapping}
|
||||
onStaticValueChange={onStaticValueChange}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Helper to get display label for static values
|
||||
const getStaticValueLabel = (value: string) => {
|
||||
if (value === "$now") return t("workspace.unify.feedback_date");
|
||||
return value;
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} className={containerClass}>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-slate-900">{field.name}</span>
|
||||
{field.required && <span className="text-xs text-red-500">*</span>}
|
||||
<span className="text-xs text-slate-400">({field.type})</span>
|
||||
</div>
|
||||
|
||||
{mappedSourceField && !mapping?.staticValue && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="text-xs text-green-700">← {mappedSourceField.name}</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="green" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mapping?.staticValue && !mappedSourceField && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<span className="rounded bg-blue-100 px-1.5 py-0.5 text-xs text-blue-700">
|
||||
= {getStaticValueLabel(mapping.staticValue)}
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasMapping && (
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||
<span className="text-xs text-slate-400">{t("workspace.unify.drop_a_field_here")}</span>
|
||||
{field.exampleStaticValues && field.exampleStaticValues.length > 0 && (
|
||||
<>
|
||||
<span className="text-xs text-slate-300">|</span>
|
||||
{field.exampleStaticValues.map((val) => (
|
||||
<button
|
||||
key={val}
|
||||
type="button"
|
||||
onClick={() => onStaticValueChange(val)}
|
||||
className="rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-600 hover:bg-slate-200">
|
||||
{getStaticValueLabel(val)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { DndContext, DragEndEvent, DragOverlay, DragStartEvent } from "@dnd-kit/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TConnectorType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, TFieldMapping, TSourceField } from "../types";
|
||||
import { DraggableSourceField, DroppableTargetField } from "./mapping-field";
|
||||
|
||||
interface MappingUIProps {
|
||||
sourceFields: TSourceField[];
|
||||
mappings: TFieldMapping[];
|
||||
onMappingsChange: (mappings: TFieldMapping[]) => void;
|
||||
connectorType: TConnectorType;
|
||||
}
|
||||
|
||||
export function MappingUI({ sourceFields, mappings, onMappingsChange, connectorType }: MappingUIProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
|
||||
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
|
||||
const optionalFields = FEEDBACK_RECORD_FIELDS.filter((f) => !f.required);
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
setActiveId(event.active.id as string);
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
setActiveId(null);
|
||||
|
||||
if (!over) return;
|
||||
|
||||
const sourceFieldId = active.id as string;
|
||||
const targetFieldId = over.id as string;
|
||||
|
||||
const newMappings = mappings.filter(
|
||||
(m) => m.sourceFieldId !== sourceFieldId && m.targetFieldId !== targetFieldId
|
||||
);
|
||||
onMappingsChange([...newMappings, { sourceFieldId, targetFieldId }]);
|
||||
};
|
||||
|
||||
const handleRemoveMapping = (targetFieldId: string) => {
|
||||
onMappingsChange(mappings.filter((m) => m.targetFieldId !== targetFieldId));
|
||||
};
|
||||
|
||||
const handleStaticValueChange = (targetFieldId: string, staticValue: string) => {
|
||||
const newMappings = mappings.filter((m) => m.targetFieldId !== targetFieldId);
|
||||
onMappingsChange([...newMappings, { targetFieldId, staticValue }]);
|
||||
};
|
||||
|
||||
const getSourceFieldById = (id: string) => sourceFields.find((f) => f.id === id);
|
||||
|
||||
const getMappingForTarget = (targetFieldId: string) => {
|
||||
return mappings.find((m) => m.targetFieldId === targetFieldId) ?? null;
|
||||
};
|
||||
|
||||
const getMappedSourceField = (targetFieldId: string) => {
|
||||
const mapping = getMappingForTarget(targetFieldId);
|
||||
return mapping?.sourceFieldId ? getSourceFieldById(mapping.sourceFieldId) : null;
|
||||
};
|
||||
|
||||
const isSourceFieldMapped = (sourceFieldId: string) =>
|
||||
mappings.some((m) => m.sourceFieldId === sourceFieldId);
|
||||
|
||||
const activeField = activeId ? getSourceFieldById(activeId) : null;
|
||||
|
||||
return (
|
||||
<DndContext onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Source Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{connectorType === "csv" ? t("workspace.unify.csv_columns") : t("workspace.unify.source_fields")}
|
||||
</h4>
|
||||
|
||||
{sourceFields.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
|
||||
<p className="text-sm text-slate-500">
|
||||
{connectorType === "csv"
|
||||
? t("workspace.unify.click_load_sample_csv")
|
||||
: t("workspace.unify.no_source_fields_loaded")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{sourceFields.map((field) => (
|
||||
<DraggableSourceField key={field.id} field={field} isMapped={isSourceFieldMapped(field.id)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Target Fields Panel */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-slate-700">
|
||||
{t("workspace.unify.feedback_record_fields")}
|
||||
</h4>
|
||||
|
||||
{/* Required Fields */}
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.unify.required")}
|
||||
</p>
|
||||
{requiredFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Optional Fields */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
||||
{t("workspace.unify.optional")}
|
||||
</p>
|
||||
{optionalFields.map((field) => (
|
||||
<DroppableTargetField
|
||||
key={field.id}
|
||||
field={field}
|
||||
mappedSourceField={getMappedSourceField(field.id) ?? null}
|
||||
mapping={getMappingForTarget(field.id)}
|
||||
onRemoveMapping={() => handleRemoveMapping(field.id)}
|
||||
onStaticValueChange={(value) => handleStaticValueChange(field.id, value)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{activeField ? (
|
||||
<div className="rounded-md border border-brand-dark bg-white p-2 text-sm shadow-lg">
|
||||
<span className="font-medium">{activeField.name}</span>
|
||||
<span className="ml-2 text-xs text-slate-500">({activeField.type})</span>
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
|
||||
vi.mock("@formbricks/types/surveys/validation", () => ({
|
||||
getTextContent: (str: string) => str,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (val: Record<string, string>, _lang: string) => val?.default ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
|
||||
blocks.flatMap((block) => block.elements),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: (headline: Record<string, string>) => headline,
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const createMockSurvey = (overrides: Partial<TSurvey> = {}): TSurvey =>
|
||||
({
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
status: "inProgress",
|
||||
createdAt: NOW,
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "What do you think?" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "How likely to recommend?" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}) as unknown as TSurvey;
|
||||
|
||||
describe("transformToUnifySurvey", () => {
|
||||
test("transforms a survey with basic elements", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey());
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
status: "active",
|
||||
createdAt: NOW,
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: "What do you think?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: "How likely to recommend?",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("filters out CTA elements", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Feedback" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-cta",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Click here" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0].id).toBe("el-text");
|
||||
});
|
||||
|
||||
test("defaults required to false when not set", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rate us" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements[0].required).toBe(false);
|
||||
});
|
||||
|
||||
test("falls back to 'Untitled' when headline is empty", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements[0].headline).toBe("Untitled");
|
||||
});
|
||||
|
||||
describe("mapSurveyStatus", () => {
|
||||
test("maps 'inProgress' to 'active'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "inProgress" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("active");
|
||||
});
|
||||
|
||||
test("maps 'paused' to 'paused'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("paused");
|
||||
});
|
||||
|
||||
test("maps 'draft' to 'draft'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
test("maps 'completed' to 'completed'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("maps unknown status to 'draft'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles multiple blocks", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
elements: [
|
||||
{ id: "el-2", type: TSurveyElementTypeEnum.Rating, headline: { default: "Q2" }, required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements).toHaveLength(2);
|
||||
expect(result.elements[0].id).toBe("el-1");
|
||||
expect(result.elements[1].id).toBe("el-2");
|
||||
});
|
||||
|
||||
test("handles empty blocks", () => {
|
||||
const survey = createMockSurvey({ blocks: [] } as Partial<TSurvey>);
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves all element types except CTA", () => {
|
||||
const elementTypes = [
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.Date,
|
||||
TSurveyElementTypeEnum.Consent,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
TSurveyElementTypeEnum.Ranking,
|
||||
TSurveyElementTypeEnum.PictureSelection,
|
||||
TSurveyElementTypeEnum.ContactInfo,
|
||||
TSurveyElementTypeEnum.Address,
|
||||
TSurveyElementTypeEnum.FileUpload,
|
||||
TSurveyElementTypeEnum.Cal,
|
||||
TSurveyElementTypeEnum.CTA,
|
||||
];
|
||||
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: elementTypes.map((type, i) => ({
|
||||
id: `el-${i.toString()}`,
|
||||
type,
|
||||
headline: { default: `Question ${i.toString()}` },
|
||||
required: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
const resultTypes = result.elements.map((e) => e.type);
|
||||
|
||||
expect(resultTypes).not.toContain(TSurveyElementTypeEnum.CTA);
|
||||
expect(result.elements).toHaveLength(elementTypes.length - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { TUnifySurvey, TUnifySurveyElement } from "./types";
|
||||
|
||||
const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => {
|
||||
return (
|
||||
getTextContent(
|
||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
||||
) || "Untitled"
|
||||
);
|
||||
};
|
||||
|
||||
const mapSurveyStatus = (status: string): TUnifySurvey["status"] => {
|
||||
switch (status) {
|
||||
case "inProgress":
|
||||
return "active";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "draft":
|
||||
return "draft";
|
||||
case "completed":
|
||||
return "completed";
|
||||
default:
|
||||
return "draft";
|
||||
}
|
||||
};
|
||||
|
||||
export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const unifySurveyElements: TUnifySurveyElement[] = elements
|
||||
.filter((el) => el.type !== TSurveyElementTypeEnum.CTA)
|
||||
.map((el) => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
headline: getElementHeadline(el, survey),
|
||||
required: el.required ?? false,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
name: survey.name,
|
||||
status: mapSurveyStatus(survey.status),
|
||||
elements: unifySurveyElements,
|
||||
createdAt: survey.createdAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function UnifySourcesPage(props: { params: Promise<{ workspaceId: string }> }) {
|
||||
const params = await props.params;
|
||||
redirect(`/workspaces/${params.workspaceId}/feedback-sources`);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { z } from "zod";
|
||||
import { THubFieldType, ZHubFieldType } from "@formbricks/types/connector";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
|
||||
|
||||
export interface TUnifySurveyElement {
|
||||
id: string;
|
||||
type: TSurveyElementTypeEnum;
|
||||
headline: string;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export interface TUnifySurvey {
|
||||
id: string;
|
||||
name: string;
|
||||
status: "draft" | "active" | "paused" | "completed";
|
||||
elements: TUnifySurveyElement[];
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export interface TFieldMapping {
|
||||
targetFieldId: string;
|
||||
sourceFieldId?: string;
|
||||
staticValue?: string;
|
||||
}
|
||||
|
||||
export type TTargetFieldType = "string" | "enum" | "timestamp" | "float64" | "boolean" | "jsonb" | "string[]";
|
||||
|
||||
export interface TTargetField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: TTargetFieldType;
|
||||
required: boolean;
|
||||
description: string;
|
||||
enumValues?: THubFieldType[];
|
||||
exampleStaticValues?: string[];
|
||||
}
|
||||
|
||||
export interface TSourceField {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
sampleValue?: string;
|
||||
}
|
||||
|
||||
export const FEEDBACK_RECORD_FIELDS: TTargetField[] = [
|
||||
{
|
||||
id: "collected_at",
|
||||
name: "Collected At",
|
||||
type: "timestamp",
|
||||
required: true,
|
||||
description: "When the feedback was originally collected",
|
||||
},
|
||||
{
|
||||
id: "source_type",
|
||||
name: "Source Type",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Type of source (e.g., survey, review, support)",
|
||||
},
|
||||
{
|
||||
id: "field_id",
|
||||
name: "Field ID",
|
||||
type: "string",
|
||||
required: true,
|
||||
description: "Unique question/field identifier",
|
||||
},
|
||||
{
|
||||
id: "field_type",
|
||||
name: "Field Type",
|
||||
type: "enum",
|
||||
required: true,
|
||||
description: "Data type (text, nps, csat, rating, etc.)",
|
||||
enumValues: ZHubFieldType.options,
|
||||
},
|
||||
{
|
||||
id: "tenant_id",
|
||||
name: "Tenant ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Tenant/organization identifier for multi-tenant deployments",
|
||||
},
|
||||
{
|
||||
id: "source_id",
|
||||
name: "Source ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Reference to survey/form/ticket/review ID",
|
||||
},
|
||||
{
|
||||
id: "source_name",
|
||||
name: "Source Name",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Human-readable source name for display",
|
||||
},
|
||||
{
|
||||
id: "field_label",
|
||||
name: "Field Label",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Question text or field label for display",
|
||||
},
|
||||
{
|
||||
id: "field_group_id",
|
||||
name: "Field Group ID",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Stable identifier grouping related fields (for ranking, matrix, grid questions)",
|
||||
},
|
||||
{
|
||||
id: "field_group_label",
|
||||
name: "Field Group Label",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Human-readable question text for the group",
|
||||
},
|
||||
{
|
||||
id: "value_text",
|
||||
name: "Value (Text)",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Text responses (feedback, comments, open-ended answers)",
|
||||
},
|
||||
{
|
||||
id: "value_number",
|
||||
name: "Value (Number)",
|
||||
type: "float64",
|
||||
required: false,
|
||||
description: "Numeric responses (ratings, scores, NPS, CSAT)",
|
||||
},
|
||||
{
|
||||
id: "value_boolean",
|
||||
name: "Value (Boolean)",
|
||||
type: "boolean",
|
||||
required: false,
|
||||
description: "Yes/no responses",
|
||||
},
|
||||
{
|
||||
id: "value_date",
|
||||
name: "Value (Date)",
|
||||
type: "timestamp",
|
||||
required: false,
|
||||
description: "Date/datetime responses",
|
||||
},
|
||||
{
|
||||
id: "metadata",
|
||||
name: "Metadata",
|
||||
type: "jsonb",
|
||||
required: false,
|
||||
description: "Flexible context (device, location, campaign, custom fields)",
|
||||
},
|
||||
{
|
||||
id: "language",
|
||||
name: "Language",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "ISO 639-1 language code (e.g., en, de, fr)",
|
||||
exampleStaticValues: ["en", "de", "fr", "es", "pt", "ja", "zh"],
|
||||
},
|
||||
{
|
||||
id: "user_identifier",
|
||||
name: "User Identifier",
|
||||
type: "string",
|
||||
required: false,
|
||||
description: "Anonymous user ID for tracking (hashed, never PII)",
|
||||
},
|
||||
];
|
||||
|
||||
export const SAMPLE_CSV_COLUMNS = "timestamp,customer_id,rating,feedback_text,category";
|
||||
|
||||
export const MAX_CSV_VALUES = {
|
||||
FILE_SIZE: 2_097_152, // 2MB (2 * 1024 * 1024)
|
||||
RECORDS: 1_000, // 1,000 records
|
||||
} as const;
|
||||
|
||||
export const createFeedbackCSVDataSchema = (t: TFunction) =>
|
||||
z
|
||||
.array(z.record(z.string(), z.string()))
|
||||
.min(1, { message: t("workspace.unify.csv_at_least_one_row") })
|
||||
.max(MAX_CSV_VALUES.RECORDS, {
|
||||
message: t("workspace.unify.csv_max_records", {
|
||||
max: MAX_CSV_VALUES.RECORDS.toLocaleString(),
|
||||
}),
|
||||
})
|
||||
.superRefine((rows, ctx) => {
|
||||
const localeSort = (a: string, b: string) => a.localeCompare(b);
|
||||
const firstRowKeys = Object.keys(rows[0]).sort(localeSort).join(",");
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const rowKeys = Object.keys(rows[i]).sort(localeSort).join(",");
|
||||
if (rowKeys !== firstRowKeys) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("workspace.unify.csv_inconsistent_columns", { row: (i + 1).toString() }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyHeaders = Object.keys(rows[0]).filter((k) => k.trim() === "");
|
||||
if (emptyHeaders.length > 0) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("workspace.unify.csv_empty_column_headers"),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
|
||||
|
||||
export type TCreateConnectorStep = "selectType" | "mapping";
|
||||
@@ -0,0 +1,117 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { MAX_CSV_VALUES, TSourceField } from "./types";
|
||||
import { getConnectorOptions, parseCSVColumnsToFields, validateCsvFile } from "./utils";
|
||||
|
||||
const mockT = (key: string) => key;
|
||||
|
||||
describe("getConnectorOptions", () => {
|
||||
test("returns formbricks, csv, api ingestion, and mcp options", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options).toHaveLength(4);
|
||||
expect(options[0].id).toBe("formbricks_survey");
|
||||
expect(options[1].id).toBe("csv");
|
||||
expect(options[2].id).toBe("api_ingestion");
|
||||
expect(options[3].id).toBe("feedback_record_mcp");
|
||||
});
|
||||
|
||||
test("both options are enabled by default", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options.every((o) => !o.disabled)).toBe(true);
|
||||
});
|
||||
|
||||
test("uses translation keys for name and description", () => {
|
||||
const options = getConnectorOptions(mockT as never);
|
||||
expect(options[0].name).toBe("workspace.unify.formbricks_surveys");
|
||||
expect(options[0].description).toBe("workspace.unify.source_connect_formbricks_description");
|
||||
expect(options[1].name).toBe("workspace.unify.csv_import");
|
||||
expect(options[1].description).toBe("workspace.unify.source_connect_csv_description");
|
||||
expect(options[2].name).toBe("workspace.unify.api_ingestion");
|
||||
expect(options[2].description).toBe("workspace.unify.api_ingestion_settings_description");
|
||||
expect(options[3].name).toBe("workspace.unify.feedback_record_mcp");
|
||||
expect(options[3].description).toBe("workspace.unify.source_connect_feedback_record_mcp_description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseCSVColumnsToFields", () => {
|
||||
test("parses comma-separated column names into source fields", () => {
|
||||
const result = parseCSVColumnsToFields("name,email,score");
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result).toEqual<TSourceField[]>([
|
||||
{ id: "name", name: "name", type: "string", sampleValue: "Sample name" },
|
||||
{ id: "email", name: "email", type: "string", sampleValue: "Sample email" },
|
||||
{ id: "score", name: "score", type: "string", sampleValue: "Sample score" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("trims whitespace from column names", () => {
|
||||
const result = parseCSVColumnsToFields(" name , email , score ");
|
||||
expect(result[0].id).toBe("name");
|
||||
expect(result[1].id).toBe("email");
|
||||
expect(result[2].id).toBe("score");
|
||||
});
|
||||
|
||||
test("handles single column", () => {
|
||||
const result = parseCSVColumnsToFields("feedback");
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe("feedback");
|
||||
});
|
||||
|
||||
test("generates sample values from column names", () => {
|
||||
const result = parseCSVColumnsToFields("rating,comment");
|
||||
expect(result[0].sampleValue).toBe("Sample rating");
|
||||
expect(result[1].sampleValue).toBe("Sample comment");
|
||||
});
|
||||
});
|
||||
|
||||
const createMockFile = (name: string, size: number, type: string): File =>
|
||||
new File(["x".repeat(size)], name, { type });
|
||||
|
||||
describe("validateCsvFile", () => {
|
||||
test("accepts a valid .csv file", () => {
|
||||
const file = createMockFile("data.csv", 1024, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("rejects a file without .csv extension", () => {
|
||||
const file = createMockFile("data.xlsx", 1024, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
|
||||
test("rejects a file with wrong MIME type", () => {
|
||||
const file = createMockFile("data.csv", 1024, "application/json");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
|
||||
test("accepts a .csv file with empty MIME type", () => {
|
||||
const file = createMockFile("data.csv", 1024, "");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("accepts a .csv file with alternative csv MIME type", () => {
|
||||
const file = createMockFile("report.csv", 512, "application/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("rejects a file exceeding the size limit", () => {
|
||||
const file = createMockFile("big.csv", MAX_CSV_VALUES.FILE_SIZE + 1, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_file_too_large" });
|
||||
});
|
||||
|
||||
test("accepts a file exactly at the size limit", () => {
|
||||
const file = createMockFile("exact.csv", MAX_CSV_VALUES.FILE_SIZE, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: true });
|
||||
});
|
||||
|
||||
test("checks extension before MIME type", () => {
|
||||
const file = createMockFile("data.txt", 100, "text/csv");
|
||||
const result = validateCsvFile(file, mockT as never);
|
||||
expect(result).toEqual({ valid: false, error: "workspace.unify.csv_files_only" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import { TFunction } from "i18next";
|
||||
import { TConnectorType, THubFieldType } from "@formbricks/types/connector";
|
||||
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
|
||||
|
||||
export type TConnectorOptionId = TConnectorType | "api_ingestion" | "feedback_record_mcp";
|
||||
|
||||
export interface TConnectorOption {
|
||||
id: TConnectorOptionId;
|
||||
name: string;
|
||||
description: string;
|
||||
disabled: boolean;
|
||||
badge?: { text: string; type: "success" | "gray" | "warning" };
|
||||
}
|
||||
|
||||
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
|
||||
{
|
||||
id: "formbricks_survey",
|
||||
name: t("workspace.unify.formbricks_surveys"),
|
||||
description: t("workspace.unify.source_connect_formbricks_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "csv",
|
||||
name: t("workspace.unify.csv_import"),
|
||||
description: t("workspace.unify.source_connect_csv_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "api_ingestion",
|
||||
name: t("workspace.unify.api_ingestion"),
|
||||
description: t("workspace.unify.api_ingestion_settings_description"),
|
||||
disabled: false,
|
||||
},
|
||||
{
|
||||
id: "feedback_record_mcp",
|
||||
name: t("workspace.unify.feedback_record_mcp"),
|
||||
description: t("workspace.unify.source_connect_feedback_record_mcp_description"),
|
||||
disabled: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const parseCSVColumnsToFields = (columns: string): TSourceField[] => {
|
||||
return columns.split(",").map((col) => {
|
||||
const trimmed = col.trim();
|
||||
return { id: trimmed, name: trimmed, type: "string", sampleValue: `Sample ${trimmed}` };
|
||||
});
|
||||
};
|
||||
|
||||
export interface TEnumValidationError {
|
||||
targetFieldName: string;
|
||||
invalidEntries: { row: number; value: string }[];
|
||||
allowedValues: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that CSV columns mapped to enum target fields contain only allowed values.
|
||||
* Returns an array of validation errors (empty if all valid).
|
||||
*/
|
||||
export const validateEnumMappings = (
|
||||
mappings: TFieldMapping[],
|
||||
csvData: Record<string, string>[]
|
||||
): TEnumValidationError[] => {
|
||||
const errors: TEnumValidationError[] = [];
|
||||
|
||||
for (const mapping of mappings) {
|
||||
if (!mapping.sourceFieldId || mapping.staticValue) continue;
|
||||
|
||||
const targetField = FEEDBACK_RECORD_FIELDS.find((f) => f.id === mapping.targetFieldId);
|
||||
if (targetField?.type !== "enum" || !targetField?.enumValues) continue;
|
||||
|
||||
const allowedValues = new Set(targetField.enumValues);
|
||||
const invalidEntries: { row: number; value: string }[] = [];
|
||||
|
||||
for (let i = 0; i < csvData.length; i++) {
|
||||
const value = csvData[i][mapping.sourceFieldId]?.trim();
|
||||
if (value && !allowedValues.has(value as THubFieldType)) {
|
||||
invalidEntries.push({ row: i + 1, value });
|
||||
}
|
||||
}
|
||||
|
||||
if (invalidEntries.length > 0) {
|
||||
errors.push({
|
||||
targetFieldName: targetField.name,
|
||||
invalidEntries,
|
||||
allowedValues: targetField.enumValues,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
export const validateCsvFile = (
|
||||
file: File,
|
||||
t: TFunction
|
||||
): { valid: true } | { valid: false; error: string } => {
|
||||
if (!file.name.endsWith(".csv")) {
|
||||
return { valid: false, error: t("workspace.unify.csv_files_only") };
|
||||
}
|
||||
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
|
||||
return { valid: false, error: t("workspace.unify.csv_files_only") };
|
||||
}
|
||||
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
|
||||
return { valid: false, error: t("workspace.unify.csv_file_too_large") };
|
||||
}
|
||||
return { valid: true };
|
||||
};
|
||||
@@ -4,10 +4,10 @@ import { v7 as uuidv7 } from "uuid";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { sendTelemetryEvents } from "@/app/api/(internal)/pipeline/lib/telemetry";
|
||||
import { ZPipelineInput } from "@/app/api/(internal)/pipeline/types/pipelines";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
|
||||
import { CRON_SECRET, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
@@ -21,11 +21,12 @@ import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { handleIntegrations } from "@/modules/response-pipeline/lib/handle-integrations";
|
||||
import { captureSurveyResponsePostHogEvent } from "@/modules/response-pipeline/lib/posthog";
|
||||
import { sendTelemetryEvents } from "@/modules/response-pipeline/lib/telemetry";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
import { captureSurveyResponsePostHogEvent } from "./lib/posthog";
|
||||
|
||||
export const POST = async (request: Request) => {
|
||||
const requestHeaders = await headers();
|
||||
@@ -153,6 +154,14 @@ export const POST = async (request: Request) => {
|
||||
});
|
||||
|
||||
if (event === "responseFinished") {
|
||||
// Handle connector pipeline for Hub integration (only on responseFinished to avoid duplicates)
|
||||
// This sends response data to the Hub for configured connectors
|
||||
try {
|
||||
await handleConnectorPipeline(response, survey, workspaceId);
|
||||
} catch (error) {
|
||||
// Log but don't throw - connector failures shouldn't break the main pipeline
|
||||
logger.error({ error, surveyId, responseId: response.id }, "Connector pipeline failed");
|
||||
}
|
||||
// Fetch integrations and responseCount in parallel
|
||||
const [integrations, responseCount] = await Promise.all([
|
||||
getIntegrations(workspaceId),
|
||||
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, ResourceNotFoundError, ValidationError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { getResponseIdByDisplayId } from "./response";
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn((inputs: [unknown, unknown][]) =>
|
||||
inputs.map((input: [unknown, unknown]) => input[0])
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
display: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("getResponseIdByDisplayId", () => {
|
||||
const workspaceId = "ws1234567890123456789012";
|
||||
const displayId = "display1234567890123456789";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns the linked responseId when a response exists", async () => {
|
||||
vi.mocked(prisma.display.findFirst).mockResolvedValue({
|
||||
response: {
|
||||
id: "response123456789012345678",
|
||||
},
|
||||
} as any);
|
||||
|
||||
const result = await getResponseIdByDisplayId(workspaceId, displayId);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith(
|
||||
[workspaceId, expect.any(Object)],
|
||||
[displayId, expect.any(Object)]
|
||||
);
|
||||
expect(prisma.display.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
id: displayId,
|
||||
survey: {
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
response: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ responseId: "response123456789012345678" });
|
||||
});
|
||||
|
||||
test("returns null when the display exists but has no response", async () => {
|
||||
vi.mocked(prisma.display.findFirst).mockResolvedValue({
|
||||
response: null,
|
||||
} as any);
|
||||
|
||||
await expect(getResponseIdByDisplayId(workspaceId, displayId)).resolves.toEqual({
|
||||
responseId: null,
|
||||
});
|
||||
});
|
||||
|
||||
test("throws ResourceNotFoundError when the display does not exist in the workspace", async () => {
|
||||
vi.mocked(prisma.display.findFirst).mockResolvedValue(null);
|
||||
|
||||
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(
|
||||
new ResourceNotFoundError("Display", displayId)
|
||||
);
|
||||
});
|
||||
|
||||
test("throws ValidationError when input validation fails", async () => {
|
||||
const validationError = new ValidationError("Validation failed");
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw validationError;
|
||||
});
|
||||
|
||||
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(ValidationError);
|
||||
expect(prisma.display.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("throws DatabaseError on Prisma request errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Database error", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(prisma.display.findFirst).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getResponseIdByDisplayId(workspaceId, displayId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
export const getResponseIdByDisplayId = async (
|
||||
workspaceId: string,
|
||||
displayId: string
|
||||
): Promise<{ responseId: string | null }> => {
|
||||
validateInputs([workspaceId, ZId], [displayId, ZId]);
|
||||
|
||||
try {
|
||||
const display = await prisma.display.findFirst({
|
||||
where: {
|
||||
id: displayId,
|
||||
survey: {
|
||||
workspaceId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
response: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!display) {
|
||||
throw new ResourceNotFoundError("Display", displayId);
|
||||
}
|
||||
|
||||
return {
|
||||
responseId: display.response?.id ?? null,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { resolveClientApiIds } from "@/lib/utils/resolve-client-id";
|
||||
import { getResponseIdByDisplayId } from "./lib/response";
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: THandlerParams<{ params: Promise<{ workspaceId: string; displayId: string }> }>) => {
|
||||
const params = await props.params;
|
||||
|
||||
const resolved = await resolveClientApiIds(params.workspaceId);
|
||||
if (!resolved) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Workspace", params.workspaceId, true),
|
||||
};
|
||||
}
|
||||
const { workspaceId } = resolved;
|
||||
|
||||
try {
|
||||
const response = await getResponseIdByDisplayId(workspaceId, params.displayId);
|
||||
|
||||
return {
|
||||
response: responses.successResponse(response, true),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Display", params.displayId, true),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ error, url: req.url, workspaceId, displayId: params.displayId },
|
||||
"Error in GET /api/v1/client/[workspaceId]/displays/[displayId]/response"
|
||||
);
|
||||
return {
|
||||
response: responses.internalServerErrorResponse("Something went wrong. Please try again."),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -33,10 +33,13 @@ vi.mock("@formbricks/logger", () => ({
|
||||
vi.mock("./data");
|
||||
vi.mock("@/app/lib/api/api-backwards-compat", () => ({
|
||||
addLegacyProjectOverwritesToList: vi.fn((surveys: unknown[]) =>
|
||||
surveys.map((s: Record<string, unknown>) => ({
|
||||
...s,
|
||||
projectOverwrites: s.workspaceOverwrites ?? null,
|
||||
}))
|
||||
surveys.map((survey) => {
|
||||
const typedSurvey = survey as Record<string, unknown>;
|
||||
return {
|
||||
...typedSurvey,
|
||||
projectOverwrites: typedSurvey.workspaceOverwrites ?? null,
|
||||
};
|
||||
})
|
||||
),
|
||||
addLegacyProjectToEnvironmentState: vi.fn((data: Record<string, unknown>) => ({
|
||||
...data,
|
||||
@@ -129,7 +132,7 @@ const mockActionClasses = [
|
||||
description: null,
|
||||
type: "code",
|
||||
noCodeConfig: null,
|
||||
environmentId: workspaceId,
|
||||
workspaceId,
|
||||
key: "action1",
|
||||
},
|
||||
] as unknown as TActionClass[];
|
||||
|
||||
@@ -126,7 +126,6 @@ export const POST = withV1ApiWrapper({
|
||||
response: responses.badRequestResponse(
|
||||
"Survey is part of another workspace",
|
||||
{
|
||||
"survey.workspaceId": survey.workspaceId,
|
||||
workspaceId,
|
||||
},
|
||||
true
|
||||
|
||||
@@ -15,20 +15,14 @@ const apiKeySelect = {
|
||||
lastUsedAt: true,
|
||||
apiKeyWorkspaces: {
|
||||
select: {
|
||||
environment: {
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
legacyEnvironmentId: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
workspaceId: true,
|
||||
name: true,
|
||||
appSetupCompleted: true,
|
||||
workspace: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
permission: true,
|
||||
@@ -44,17 +38,13 @@ type ApiKeyData = {
|
||||
lastUsedAt: Date | null;
|
||||
apiKeyWorkspaces: Array<{
|
||||
permission: string;
|
||||
environment: {
|
||||
workspace: {
|
||||
id: string;
|
||||
type: string;
|
||||
legacyEnvironmentId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
workspaceId: string;
|
||||
name: string;
|
||||
appSetupCompleted: boolean;
|
||||
workspace: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
};
|
||||
@@ -116,21 +106,24 @@ const updateApiKeyUsage = async (apiKeyId: string) => {
|
||||
});
|
||||
};
|
||||
|
||||
const buildEnvironmentResponse = (apiKeyData: ApiKeyData) => {
|
||||
const env = apiKeyData.apiKeyWorkspaces[0].environment;
|
||||
const buildWorkspaceResponse = (apiKeyData: ApiKeyData) => {
|
||||
const workspace = apiKeyData.apiKeyWorkspaces[0].workspace;
|
||||
return Response.json({
|
||||
id: env.id,
|
||||
type: env.type,
|
||||
createdAt: env.createdAt,
|
||||
updatedAt: env.updatedAt,
|
||||
appSetupCompleted: env.appSetupCompleted,
|
||||
// Keep v1 payload shape stable while sourcing data from workspace.
|
||||
id: workspace.legacyEnvironmentId ?? workspace.id,
|
||||
type: "production",
|
||||
createdAt: workspace.createdAt,
|
||||
updatedAt: workspace.updatedAt,
|
||||
appSetupCompleted: workspace.appSetupCompleted,
|
||||
workspace: {
|
||||
id: env.workspaceId,
|
||||
name: env.workspace.name,
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
},
|
||||
// Backwards compat: old consumers expect project fields
|
||||
projectId: env.workspaceId,
|
||||
projectName: env.workspace.name,
|
||||
project: {
|
||||
id: workspace.id,
|
||||
name: workspace.name,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@@ -157,14 +150,12 @@ const handleApiKeyAuthentication = async (apiKey: string) => {
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitError = await checkRateLimit(apiKeyData.id);
|
||||
if (rateLimitError) return rateLimitError;
|
||||
|
||||
// Rate limiting for apiKey auth is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
|
||||
if (!isValidApiKeyEnvironment(apiKeyData)) {
|
||||
return responses.badRequestResponse("You can't use this method with this API key");
|
||||
}
|
||||
|
||||
return buildEnvironmentResponse(apiKeyData);
|
||||
return buildWorkspaceResponse(apiKeyData);
|
||||
};
|
||||
|
||||
const handleSessionAuthentication = async () => {
|
||||
|
||||
@@ -73,7 +73,6 @@ const validateSurvey = async (responseInput: TResponseInput, workspaceId: string
|
||||
error: responses.badRequestResponse(
|
||||
"Survey is part of another workspace",
|
||||
{
|
||||
"survey.workspaceId": survey.workspaceId,
|
||||
workspaceId,
|
||||
},
|
||||
true
|
||||
|
||||
@@ -1,43 +1,16 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { deleteSurvey } from "./surveys";
|
||||
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
const { mockDeleteSharedSurvey } = vi.hoisted(() => ({
|
||||
mockDeleteSharedSurvey: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
survey: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
segment: {
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
|
||||
vi.mock("@/modules/survey/lib/surveys", () => ({
|
||||
deleteSurvey: mockDeleteSharedSurvey,
|
||||
}));
|
||||
|
||||
const surveyId = "clq5n7p1q0000m7z0h5p6g3r2";
|
||||
const workspaceId = "clq5n7p1q0000m7z0h5p6g3r3";
|
||||
const segmentId = "clq5n7p1q0000m7z0h5p6g3r4";
|
||||
const actionClassId1 = "clq5n7p1q0000m7z0h5p6g3r5";
|
||||
const actionClassId2 = "clq5n7p1q0000m7z0h5p6g3r6";
|
||||
|
||||
const mockDeletedSurveyAppPrivateSegment = {
|
||||
id: surveyId,
|
||||
workspaceId,
|
||||
type: "app",
|
||||
segment: { id: segmentId, isPrivate: true },
|
||||
triggers: [{ actionClass: { id: actionClassId1 } }, { actionClass: { id: actionClassId2 } }],
|
||||
};
|
||||
|
||||
const mockDeletedSurveyLink = {
|
||||
id: surveyId,
|
||||
@@ -56,66 +29,20 @@ describe("deleteSurvey", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("should delete a link survey without a segment and revalidate caches", async () => {
|
||||
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyLink as any);
|
||||
test("delegates survey deletion to the shared service", async () => {
|
||||
mockDeleteSharedSurvey.mockResolvedValue(mockDeletedSurveyLink);
|
||||
|
||||
const deletedSurvey = await deleteSurvey(surveyId);
|
||||
|
||||
expect(validateInputs).toHaveBeenCalledWith([surveyId, expect.any(Object)]);
|
||||
expect(prisma.survey.delete).toHaveBeenCalledWith({
|
||||
where: { id: surveyId },
|
||||
include: {
|
||||
segment: true,
|
||||
triggers: { include: { actionClass: true } },
|
||||
},
|
||||
});
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
|
||||
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
|
||||
expect(deletedSurvey).toEqual(mockDeletedSurveyLink);
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError during survey deletion", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Record not found", {
|
||||
code: "P2025",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle PrismaClientKnownRequestError during segment deletion", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Foreign key constraint failed", {
|
||||
code: "P2003",
|
||||
clientVersion: "4.0.0",
|
||||
});
|
||||
vi.mocked(prisma.survey.delete).mockResolvedValue(mockDeletedSurveyAppPrivateSegment as any);
|
||||
vi.mocked(prisma.segment.delete).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(DatabaseError);
|
||||
expect(logger.error).toHaveBeenCalledWith({ error: prismaError, surveyId }, "Error deleting survey");
|
||||
expect(prisma.segment.delete).toHaveBeenCalledWith({ where: { id: segmentId } });
|
||||
});
|
||||
|
||||
test("should handle generic errors during deletion", async () => {
|
||||
test("rethrows shared delete service errors", async () => {
|
||||
const genericError = new Error("Something went wrong");
|
||||
vi.mocked(prisma.survey.delete).mockRejectedValue(genericError);
|
||||
mockDeleteSharedSurvey.mockRejectedValue(genericError);
|
||||
|
||||
await expect(deleteSurvey(surveyId)).rejects.toThrow(genericError);
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(prisma.segment.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw validation error for invalid surveyId", async () => {
|
||||
const invalidSurveyId = "invalid-id";
|
||||
const validationError = new Error("Validation failed");
|
||||
vi.mocked(validateInputs).mockImplementation(() => {
|
||||
throw validationError;
|
||||
});
|
||||
|
||||
await expect(deleteSurvey(invalidSurveyId)).rejects.toThrow(validationError);
|
||||
expect(prisma.survey.delete).not.toHaveBeenCalled();
|
||||
expect(mockDeleteSharedSurvey).toHaveBeenCalledWith(surveyId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,43 +1,3 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { deleteSurvey as deleteSharedSurvey } from "@/modules/survey/lib/surveys";
|
||||
|
||||
export const deleteSurvey = async (surveyId: string) => {
|
||||
validateInputs([surveyId, z.cuid2()]);
|
||||
|
||||
try {
|
||||
const deletedSurvey = await prisma.survey.delete({
|
||||
where: {
|
||||
id: surveyId,
|
||||
},
|
||||
include: {
|
||||
segment: true,
|
||||
triggers: {
|
||||
include: {
|
||||
actionClass: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (deletedSurvey.type === "app" && deletedSurvey.segment?.isPrivate) {
|
||||
await prisma.segment.delete({
|
||||
where: {
|
||||
id: deletedSurvey.segment.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return deletedSurvey;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error({ error, surveyId }, "Error deleting survey");
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
export const deleteSurvey = async (surveyId: string) => deleteSharedSurvey(surveyId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { ZSurveyUpdateInput } from "@formbricks/types/surveys/types";
|
||||
import { handleErrorResponse } from "@/app/api/v1/auth";
|
||||
import { deleteSurvey } from "@/app/api/v1/management/surveys/[surveyId]/lib/surveys";
|
||||
@@ -78,6 +79,12 @@ export const GET = withV1ApiWrapper({
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
return {
|
||||
response: responses.notFoundResponse("Survey", params.surveyId),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: handleErrorResponse(error),
|
||||
};
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import { type NextRequest } from "next/server";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
applyIPRateLimit: vi.fn(),
|
||||
getWorkspaceState: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
contextualLoggerError: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/client/[workspaceId]/environment/lib/environmentState", () => ({
|
||||
getWorkspaceState: mocks.getWorkspaceState,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
resolveClientApiIds: mocks.resolveClientApiIds,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyIPRateLimit: mocks.applyIPRateLimit,
|
||||
applyRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
client: { windowMs: 60000, max: 100 },
|
||||
v1: { windowMs: 60000, max: 1000 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
withScope: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
error: mocks.contextualLoggerError,
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
})),
|
||||
error: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return {
|
||||
...actual,
|
||||
AUDIT_LOG_ENABLED: false,
|
||||
IS_PRODUCTION: true,
|
||||
SENTRY_DSN: "test-dsn",
|
||||
ENCRYPTION_KEY: "test-key",
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
};
|
||||
});
|
||||
|
||||
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
|
||||
const parsedUrl = new URL(url);
|
||||
|
||||
return {
|
||||
method: "GET",
|
||||
url,
|
||||
headers: {
|
||||
get: (key: string) => headers.get(key),
|
||||
},
|
||||
nextUrl: {
|
||||
pathname: parsedUrl.pathname,
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
describe("api/v2 client environment route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.applyIPRateLimit.mockResolvedValue(undefined);
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: "ck12345678901234567890123" });
|
||||
});
|
||||
|
||||
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
|
||||
const underlyingError = new Error("Environment load failed");
|
||||
mocks.getWorkspaceState.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = createMockRequest(
|
||||
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
|
||||
new Map([["x-request-id", "req-v2-env"]])
|
||||
);
|
||||
|
||||
const { GET } = await import("../../../../v1/client/[workspaceId]/environment/route");
|
||||
const response = await GET(request, {
|
||||
params: Promise.resolve({
|
||||
workspaceId: "ck12345678901234567890123",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "An error occurred while processing your request.",
|
||||
details: {},
|
||||
});
|
||||
|
||||
expect(Sentry.withScope).not.toHaveBeenCalled();
|
||||
expect(Sentry.captureException).toHaveBeenCalledWith(
|
||||
underlyingError,
|
||||
expect.objectContaining({
|
||||
tags: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-v2-env",
|
||||
method: "GET",
|
||||
path: "/api/v2/client/ck12345678901234567890123/environment",
|
||||
}),
|
||||
extra: expect.objectContaining({
|
||||
error: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "Environment load failed",
|
||||
}),
|
||||
originalError: expect.objectContaining({
|
||||
name: "Error",
|
||||
message: "Environment load failed",
|
||||
}),
|
||||
}),
|
||||
contexts: expect.objectContaining({
|
||||
apiRequest: expect.objectContaining({
|
||||
apiVersion: "v2",
|
||||
correlationId: "req-v2-env",
|
||||
method: "GET",
|
||||
path: "/api/v2/client/ck12345678901234567890123/environment",
|
||||
status: 500,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createDisplay: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
getOrganizationIdFromWorkspaceId: vi.fn(),
|
||||
reportApiError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/display", () => ({
|
||||
createDisplay: mocks.createDisplay,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsContactsEnabled: mocks.getIsContactsEnabled,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
resolveClientApiIds: mocks.resolveClientApiIds,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: mocks.reportApiError,
|
||||
}));
|
||||
|
||||
const environmentId = "cld1234567890abcdef123456";
|
||||
const surveyId = "clg123456789012345678901234";
|
||||
|
||||
describe("api/v2 client displays route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
|
||||
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
|
||||
mocks.getIsContactsEnabled.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: "{",
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ workspaceId: environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual(
|
||||
expect.objectContaining({
|
||||
code: "bad_request",
|
||||
message: "Invalid JSON in request body",
|
||||
})
|
||||
);
|
||||
expect(mocks.createDisplay).not.toHaveBeenCalled();
|
||||
expect(mocks.reportApiError).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
|
||||
const underlyingError = new Error("display persistence failed");
|
||||
mocks.createDisplay.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ workspaceId: environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
});
|
||||
|
||||
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
|
||||
const underlyingError = new Error("license lookup failed");
|
||||
mocks.getOrganizationIdFromWorkspaceId.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
contactId: "clh123456789012345678901234",
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ workspaceId: environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
expect(mocks.createDisplay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull, TSurveyQuota } from "@formbricks/types/quota";
|
||||
import { TResponse } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -178,10 +178,34 @@ describe("createResponse V2", () => {
|
||||
).rejects.toThrow(ResourceNotFoundError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
test("should throw UniqueConstraintError on P2002 with singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["surveyId", "singleUseId"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(UniqueConstraintError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on P2002 without singleUseId target", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
meta: { target: ["displayId"] },
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
createResponse(mockResponseInput, mockTx as unknown as Prisma.TransactionClient)
|
||||
).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("should throw DatabaseError on non-P2002 Prisma known request error", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(mockTx.response.create).mockRejectedValue(prismaError);
|
||||
await expect(
|
||||
|
||||
@@ -2,7 +2,7 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContactAttributes } from "@formbricks/types/contact-attribute";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { TResponse, ZResponseInput } from "@formbricks/types/responses";
|
||||
import { TTag } from "@formbricks/types/tags";
|
||||
@@ -131,6 +131,13 @@ export const createResponse = async (
|
||||
return response;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
if (error.code === "P2002") {
|
||||
const target = (error.meta?.target as string[]) ?? [];
|
||||
if (target?.includes("singleUseId")) {
|
||||
throw new UniqueConstraintError("Response already submitted for this single-use link");
|
||||
}
|
||||
}
|
||||
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
|
||||
@@ -92,6 +92,7 @@ const mockSurvey: TSurvey = {
|
||||
isCaptureIpEnabled: false,
|
||||
metadata: {},
|
||||
slug: null,
|
||||
isAutoProgressingEnabled: true,
|
||||
};
|
||||
|
||||
const mockResponseInput: TResponseInputV2 = {
|
||||
@@ -126,7 +127,6 @@ describe("checkSurveyValidity", () => {
|
||||
expect(responses.badRequestResponse).toHaveBeenCalledWith(
|
||||
"Survey is part of another workspace",
|
||||
{
|
||||
"survey.workspaceId": "ws-2",
|
||||
workspaceId: "ws-1",
|
||||
},
|
||||
true
|
||||
|
||||
@@ -20,7 +20,6 @@ export const checkSurveyValidity = async (
|
||||
return responses.badRequestResponse(
|
||||
"Survey is part of another workspace",
|
||||
{
|
||||
"survey.workspaceId": survey.workspaceId,
|
||||
workspaceId,
|
||||
},
|
||||
true
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
checkSurveyValidity: vi.fn(),
|
||||
createResponseWithQuotaEvaluation: vi.fn(),
|
||||
getClientIpFromHeaders: vi.fn(),
|
||||
getIsContactsEnabled: vi.fn(),
|
||||
getOrganizationIdFromWorkspaceId: vi.fn(),
|
||||
getSurvey: vi.fn(),
|
||||
reportApiError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v2/client/[workspaceId]/responses/lib/utils", () => ({
|
||||
checkSurveyValidity: mocks.checkSurveyValidity,
|
||||
}));
|
||||
|
||||
vi.mock("./lib/response", () => ({
|
||||
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/api-error-reporter", () => ({
|
||||
reportApiError: mocks.reportApiError,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/pipelines", () => ({
|
||||
sendToPipeline: mocks.sendToPipeline,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: mocks.getSurvey,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/client-ip", () => ({
|
||||
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/helper", () => ({
|
||||
getOrganizationIdFromWorkspaceId: mocks.getOrganizationIdFromWorkspaceId,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/resolve-client-id", () => ({
|
||||
resolveClientApiIds: mocks.resolveClientApiIds,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/api/lib/validation", () => ({
|
||||
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
|
||||
validateResponseData: mocks.validateResponseData,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
|
||||
getIsContactsEnabled: mocks.getIsContactsEnabled,
|
||||
}));
|
||||
|
||||
const environmentId = "cld1234567890abcdef123456";
|
||||
const surveyId = "clg123456789012345678901234";
|
||||
|
||||
describe("api/v2 client responses route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId: environmentId });
|
||||
mocks.checkSurveyValidity.mockResolvedValue(null);
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
id: surveyId,
|
||||
environmentId,
|
||||
blocks: [],
|
||||
questions: [],
|
||||
isCaptureIpEnabled: false,
|
||||
});
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
mocks.getOrganizationIdFromWorkspaceId.mockResolvedValue("org_123");
|
||||
mocks.getIsContactsEnabled.mockResolvedValue(true);
|
||||
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
|
||||
});
|
||||
|
||||
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
|
||||
const underlyingError = new Error("response persistence failed");
|
||||
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-v2-response",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ workspaceId: environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
|
||||
const underlyingError = new Error("survey lookup failed");
|
||||
mocks.getSurvey.mockRejectedValue(underlyingError);
|
||||
|
||||
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-request-id": "req-v2-response-pre-check",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
surveyId,
|
||||
finished: false,
|
||||
data: {},
|
||||
}),
|
||||
});
|
||||
|
||||
const { POST } = await import("./route");
|
||||
const response = await POST(request, {
|
||||
params: Promise.resolve({ workspaceId: environmentId }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.json()).toEqual({
|
||||
code: "internal_server_error",
|
||||
message: "Something went wrong. Please try again.",
|
||||
details: {},
|
||||
});
|
||||
expect(mocks.reportApiError).toHaveBeenCalledWith({
|
||||
request,
|
||||
status: 500,
|
||||
error: underlyingError,
|
||||
});
|
||||
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UAParser } from "ua-parser-js";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { InvalidInputError, UniqueConstraintError } from "@formbricks/types/errors";
|
||||
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
|
||||
import { checkSurveyValidity } from "@/app/api/v2/client/[workspaceId]/responses/lib/utils";
|
||||
import { reportApiError } from "@/app/lib/api/api-error-reporter";
|
||||
@@ -164,6 +164,10 @@ const createResponseForRequest = async ({
|
||||
return responses.badRequestResponse(error.message, undefined, true);
|
||||
}
|
||||
|
||||
if (error instanceof UniqueConstraintError) {
|
||||
return responses.conflictResponse(error.message, undefined, true);
|
||||
}
|
||||
|
||||
const response = getUnexpectedPublicErrorResponse();
|
||||
reportApiError({
|
||||
request,
|
||||
|
||||
@@ -9,6 +9,22 @@ const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
||||
mockGetServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
|
||||
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
|
||||
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
|
||||
action,
|
||||
targetType,
|
||||
userId: "unknown",
|
||||
targetId: "unknown",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
oldObject: undefined,
|
||||
newObject: undefined,
|
||||
userType: "api",
|
||||
apiUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: mockGetServerSession,
|
||||
}));
|
||||
@@ -25,6 +41,14 @@ vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEvent: mockQueueAuditEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/with-api-logging", () => ({
|
||||
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
@@ -45,6 +69,114 @@ describe("withV3ApiWrapper", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("passes an audit log to the handler and queues success after the response", async () => {
|
||||
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
|
||||
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "Test", email: "t@example.com" },
|
||||
expires: "2026-01-01",
|
||||
});
|
||||
|
||||
const handler = vi.fn(async ({ auditLog }) => {
|
||||
expect(auditLog).toEqual(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "failure",
|
||||
})
|
||||
);
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = "survey_1";
|
||||
auditLog.organizationId = "org_1";
|
||||
auditLog.oldObject = { id: "survey_1" };
|
||||
}
|
||||
|
||||
return Response.json({ ok: true });
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
handler,
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
|
||||
method: "DELETE",
|
||||
headers: { "x-request-id": "req-audit" },
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: "survey_1",
|
||||
organizationId: "org_1",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "success",
|
||||
oldObject: { id: "survey_1" },
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("queues a failure audit log when the handler returns a non-ok response", async () => {
|
||||
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
|
||||
|
||||
mockAuthenticateRequest.mockResolvedValue({
|
||||
type: "apiKey",
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: { accessControl: { read: true, write: true } },
|
||||
environmentPermissions: [],
|
||||
});
|
||||
|
||||
const wrapped = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
handler: async ({ auditLog }) => {
|
||||
if (auditLog) {
|
||||
auditLog.targetId = "survey_2";
|
||||
}
|
||||
|
||||
return new Response("forbidden", { status: 403 });
|
||||
},
|
||||
});
|
||||
|
||||
const response = await wrapped(
|
||||
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"x-request-id": "req-failure-audit",
|
||||
"x-api-key": "fbk_test",
|
||||
},
|
||||
}),
|
||||
{} as never
|
||||
);
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: "survey_2",
|
||||
organizationId: "org_1",
|
||||
userId: "key_1",
|
||||
userType: "api",
|
||||
status: "failure",
|
||||
eventId: "req-failure-audit",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("uses session auth first in both mode and injects request id into plain responses", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
mockGetServerSession.mockResolvedValue({
|
||||
|
||||
@@ -4,10 +4,13 @@ import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TooManyRequestsError } from "@formbricks/types/errors";
|
||||
import { authenticateRequest } from "@/app/api/v1/auth";
|
||||
import { buildAuditLogBaseObject } from "@/app/lib/api/with-api-logging";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import type { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditAction, TAuditTarget } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import {
|
||||
type InvalidParam,
|
||||
problemBadRequest,
|
||||
@@ -15,7 +18,7 @@ import {
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
} from "./response";
|
||||
import type { TV3Authentication } from "./types";
|
||||
import type { TV3AuditLog, TV3Authentication } from "./types";
|
||||
|
||||
type TV3Schema = z.ZodTypeAny;
|
||||
type MaybePromise<T> = T | Promise<T>;
|
||||
@@ -38,6 +41,7 @@ export type TV3HandlerParams<TParsedInput = Record<string, never>, TProps = unkn
|
||||
req: NextRequest;
|
||||
props: TProps;
|
||||
authentication: TV3Authentication;
|
||||
auditLog?: TV3AuditLog;
|
||||
parsedInput: TParsedInput;
|
||||
requestId: string;
|
||||
instance: string;
|
||||
@@ -48,6 +52,8 @@ export type TWithV3ApiWrapperParams<S extends TV3Schemas | undefined, TProps = u
|
||||
schemas?: S;
|
||||
rateLimit?: boolean;
|
||||
customRateLimitConfig?: TRateLimitConfig;
|
||||
action?: TAuditAction;
|
||||
targetType?: TAuditTarget;
|
||||
handler: (params: TV3HandlerParams<TV3ParsedInput<S>, TProps>) => MaybePromise<Response>;
|
||||
};
|
||||
|
||||
@@ -293,10 +299,61 @@ async function applyV3RateLimitOrRespond(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildV3AuditLog(
|
||||
authentication: TV3Authentication,
|
||||
action?: TAuditAction,
|
||||
targetType?: TAuditTarget,
|
||||
apiUrl?: string
|
||||
): TV3AuditLog | undefined {
|
||||
if (!authentication || !action || !targetType || !apiUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const auditLog = buildAuditLogBaseObject(action, targetType, apiUrl);
|
||||
|
||||
if ("user" in authentication && authentication.user?.id) {
|
||||
auditLog.userId = authentication.user.id;
|
||||
auditLog.userType = "user";
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
auditLog.userId = authentication.apiKeyId;
|
||||
auditLog.userType = "api";
|
||||
auditLog.organizationId = authentication.organizationId;
|
||||
}
|
||||
|
||||
return auditLog;
|
||||
}
|
||||
|
||||
async function queueV3AuditLog(
|
||||
auditLog: TV3AuditLog | undefined,
|
||||
requestId: string,
|
||||
log: ReturnType<typeof logger.withContext>
|
||||
): Promise<void> {
|
||||
if (!auditLog) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await queueAuditEvent({
|
||||
...auditLog,
|
||||
...(auditLog.status === "failure" ? { eventId: auditLog.eventId ?? requestId } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
log.error({ error }, "Failed to queue V3 audit event");
|
||||
}
|
||||
}
|
||||
|
||||
export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unknown>(
|
||||
params: TWithV3ApiWrapperParams<S, TProps>
|
||||
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
|
||||
const { auth = "both", schemas, rateLimit = true, customRateLimitConfig, handler } = params;
|
||||
const {
|
||||
auth = "both",
|
||||
schemas,
|
||||
rateLimit = true,
|
||||
customRateLimitConfig,
|
||||
handler,
|
||||
action,
|
||||
targetType,
|
||||
} = params;
|
||||
|
||||
return async (req: NextRequest, props: TProps): Promise<Response> => {
|
||||
const requestId = req.headers.get("x-request-id") ?? crypto.randomUUID();
|
||||
@@ -306,6 +363,7 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
|
||||
method: req.method,
|
||||
path: instance,
|
||||
});
|
||||
let auditLog: TV3AuditLog | undefined;
|
||||
|
||||
try {
|
||||
const authResult = await authenticateV3RequestOrRespond(req, auth, requestId, instance);
|
||||
@@ -331,17 +389,33 @@ export const withV3ApiWrapper = <S extends TV3Schemas | undefined, TProps = unkn
|
||||
return rateLimitResponse;
|
||||
}
|
||||
|
||||
auditLog = buildV3AuditLog(authResult.authentication, action, targetType, req.url);
|
||||
|
||||
const response = await handler({
|
||||
req,
|
||||
props,
|
||||
authentication: authResult.authentication,
|
||||
auditLog,
|
||||
parsedInput: parsedInputResult.parsedInput,
|
||||
requestId,
|
||||
instance,
|
||||
});
|
||||
|
||||
if (auditLog) {
|
||||
if (response.ok) {
|
||||
auditLog.status = "success";
|
||||
} else {
|
||||
auditLog.eventId = requestId;
|
||||
}
|
||||
}
|
||||
|
||||
await queueV3AuditLog(auditLog, requestId, log);
|
||||
return ensureRequestIdHeader(response, requestId);
|
||||
} catch (error) {
|
||||
if (auditLog) {
|
||||
auditLog.eventId = requestId;
|
||||
await queueV3AuditLog(auditLog, requestId, log);
|
||||
}
|
||||
log.error({ error, statusCode: 500 }, "V3 API unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
problemTooManyRequests,
|
||||
problemUnauthorized,
|
||||
successListResponse,
|
||||
successResponse,
|
||||
} from "./response";
|
||||
|
||||
describe("v3 problem responses", () => {
|
||||
@@ -93,3 +94,27 @@ describe("successListResponse", () => {
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=0");
|
||||
});
|
||||
});
|
||||
|
||||
describe("successResponse", () => {
|
||||
test("wraps the payload in a data envelope", async () => {
|
||||
const res = successResponse({ id: "survey_1" }, { requestId: "req-success" });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("X-Request-Id")).toBe("req-success");
|
||||
expect(res.headers.get("Cache-Control")).toContain("no-store");
|
||||
expect(await res.json()).toEqual({
|
||||
data: { id: "survey_1" },
|
||||
});
|
||||
});
|
||||
|
||||
test("allows custom status and cache headers", async () => {
|
||||
const res = successResponse(
|
||||
{ ok: true },
|
||||
{
|
||||
cache: "private, max-age=60",
|
||||
status: 202,
|
||||
}
|
||||
);
|
||||
expect(res.status).toBe(202);
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, max-age=60");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -147,3 +147,27 @@ export function successListResponse<T, TMeta extends Record<string, unknown>>(
|
||||
}
|
||||
return Response.json({ data, meta }, { status: 200, headers });
|
||||
}
|
||||
|
||||
export function successResponse<T>(
|
||||
data: T,
|
||||
options?: { requestId?: string; cache?: string; status?: number }
|
||||
): Response {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": options?.cache ?? CACHE_NO_STORE,
|
||||
};
|
||||
|
||||
if (options?.requestId) {
|
||||
headers["X-Request-Id"] = options.requestId;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
data,
|
||||
},
|
||||
{
|
||||
status: options?.status ?? 200,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { Session } from "next-auth";
|
||||
import type { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import type { TApiAuditLog } from "@/app/lib/api/with-api-logging";
|
||||
|
||||
export type TV3Authentication = TAuthenticationApiKey | Session | null;
|
||||
export type TV3AuditLog = TApiAuditLog;
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
import { ApiKeyPermission } from "@prisma/client";
|
||||
import { NextRequest } from "next/server";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { deleteSurvey } from "@/modules/survey/lib/surveys";
|
||||
import { DELETE } from "./route";
|
||||
|
||||
const { mockAuthenticateRequest } = vi.hoisted(() => ({
|
||||
mockAuthenticateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
|
||||
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
|
||||
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
|
||||
action,
|
||||
targetType,
|
||||
userId: "unknown",
|
||||
targetId: "unknown",
|
||||
organizationId: "unknown",
|
||||
status: "failure",
|
||||
oldObject: undefined,
|
||||
newObject: undefined,
|
||||
userType: "api",
|
||||
apiUrl,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("next-auth", () => ({
|
||||
getServerSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
|
||||
return { ...actual, authenticateRequest: mockAuthenticateRequest };
|
||||
});
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/constants", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/constants")>();
|
||||
return { ...actual, AUDIT_LOG_ENABLED: false };
|
||||
});
|
||||
|
||||
vi.mock("@/app/api/v3/lib/auth", () => ({
|
||||
requireV3WorkspaceAccess: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/lib/surveys", () => ({
|
||||
deleteSurvey: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
queueAuditEvent: mockQueueAuditEvent,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/with-api-logging", () => ({
|
||||
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
withContext: vi.fn(() => ({
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
|
||||
const queueAuditEvent = vi.mocked((await import("@/modules/ee/audit-logs/lib/handler")).queueAuditEvent);
|
||||
|
||||
const surveyId = "clxx1234567890123456789012";
|
||||
const workspaceId = "clzz9876543210987654321098";
|
||||
|
||||
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
|
||||
const headers: Record<string, string> = { ...extraHeaders };
|
||||
if (requestId) {
|
||||
headers["x-request-id"] = requestId;
|
||||
}
|
||||
|
||||
return new NextRequest(url, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
const apiKeyAuth = {
|
||||
type: "apiKey" as const,
|
||||
apiKeyId: "key_1",
|
||||
organizationId: "org_1",
|
||||
organizationAccess: {
|
||||
accessControl: { read: true, write: true },
|
||||
},
|
||||
workspacePermissions: [
|
||||
{
|
||||
workspaceId,
|
||||
workspaceName: "W",
|
||||
permission: ApiKeyPermission.write,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("DELETE /api/v3/surveys/[surveyId]", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
getServerSession.mockResolvedValue({
|
||||
user: { id: "user_1", name: "User", email: "u@example.com" },
|
||||
expires: "2026-01-01",
|
||||
} as any);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
vi.mocked(getSurvey).mockResolvedValue({
|
||||
id: surveyId,
|
||||
name: "Delete me",
|
||||
workspaceId: workspaceId,
|
||||
type: "link",
|
||||
status: "draft",
|
||||
createdAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-15T10:00:00.000Z"),
|
||||
responseCount: 0,
|
||||
creator: { name: "User" },
|
||||
singleUse: null,
|
||||
} as any);
|
||||
vi.mocked(deleteSurvey).mockResolvedValue({
|
||||
id: surveyId,
|
||||
workspaceId,
|
||||
type: "link",
|
||||
segment: null,
|
||||
triggers: [],
|
||||
} as any);
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValue({
|
||||
workspaceId,
|
||||
organizationId: "org_1",
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("returns 401 when no session and no API key", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(null);
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 200 with session auth and deletes the survey", async () => {
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-delete"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ user: expect.any(Object) }),
|
||||
workspaceId,
|
||||
"readWrite",
|
||||
"req-delete",
|
||||
`/api/v3/surveys/${surveyId}`
|
||||
);
|
||||
expect(deleteSurvey).toHaveBeenCalledWith(surveyId);
|
||||
expect(await res.json()).toEqual({
|
||||
data: {
|
||||
id: surveyId,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("returns 200 with x-api-key when the key can delete in the survey workspace", async () => {
|
||||
getServerSession.mockResolvedValue(null);
|
||||
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
|
||||
|
||||
const res = await DELETE(
|
||||
createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-api-key", {
|
||||
"x-api-key": "fbk_test",
|
||||
}),
|
||||
{
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ apiKeyId: "key_1" }),
|
||||
workspaceId,
|
||||
"readWrite",
|
||||
"req-api-key",
|
||||
`/api/v3/surveys/${surveyId}`
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 400 when surveyId is invalid", async () => {
|
||||
const res = await DELETE(createRequest("http://localhost/api/v3/surveys/not-a-cuid"), {
|
||||
params: Promise.resolve({ surveyId: "not-a-cuid" }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(vi.mocked(getSurvey)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when the survey does not exist", async () => {
|
||||
vi.mocked(getSurvey).mockResolvedValueOnce(null);
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(deleteSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns 403 when the user lacks readWrite workspace access", async () => {
|
||||
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
title: "Forbidden",
|
||||
status: 403,
|
||||
detail: "You are not authorized to access this resource",
|
||||
requestId: "req-forbidden",
|
||||
}),
|
||||
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-forbidden"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
expect(deleteSurvey).not.toHaveBeenCalled();
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: "unknown",
|
||||
organizationId: "unknown",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "failure",
|
||||
oldObject: undefined,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("returns 500 when survey deletion fails", async () => {
|
||||
vi.mocked(deleteSurvey).mockRejectedValueOnce(new DatabaseError("db down"));
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-db"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("internal_server_error");
|
||||
});
|
||||
|
||||
test("returns 403 when the survey is deleted after authorization succeeds", async () => {
|
||||
vi.mocked(deleteSurvey).mockRejectedValueOnce(new ResourceNotFoundError("Survey", surveyId));
|
||||
|
||||
const res = await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-race"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(res.status).toBe(403);
|
||||
const body = await res.json();
|
||||
expect(body.code).toBe("forbidden");
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: surveyId,
|
||||
organizationId: "org_1",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "failure",
|
||||
oldObject: expect.objectContaining({
|
||||
id: surveyId,
|
||||
workspaceId: workspaceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("queues an audit log with target, actor, organization, and old object", async () => {
|
||||
await DELETE(createRequest(`http://localhost/api/v3/surveys/${surveyId}`, "req-audit"), {
|
||||
params: Promise.resolve({ surveyId }),
|
||||
} as never);
|
||||
|
||||
expect(queueAuditEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
targetId: surveyId,
|
||||
organizationId: "org_1",
|
||||
userId: "user_1",
|
||||
userType: "user",
|
||||
status: "success",
|
||||
oldObject: expect.objectContaining({
|
||||
id: surveyId,
|
||||
workspaceId: workspaceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { withV3ApiWrapper } from "@/app/api/v3/lib/api-wrapper";
|
||||
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
|
||||
import { problemForbidden, problemInternalError, successResponse } from "@/app/api/v3/lib/response";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { deleteSurvey } from "@/modules/survey/lib/surveys";
|
||||
|
||||
export const DELETE = withV3ApiWrapper({
|
||||
auth: "both",
|
||||
action: "deleted",
|
||||
targetType: "survey",
|
||||
schemas: {
|
||||
params: z.object({
|
||||
surveyId: z.cuid2(),
|
||||
}),
|
||||
},
|
||||
handler: async ({ parsedInput, authentication, requestId, instance, auditLog }) => {
|
||||
const surveyId = parsedInput.params.surveyId;
|
||||
const log = logger.withContext({ requestId, surveyId });
|
||||
|
||||
try {
|
||||
const survey = await getSurvey(surveyId);
|
||||
|
||||
if (!survey) {
|
||||
log.warn({ statusCode: 403 }, "Survey not found or not accessible");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
const authResult = await requireV3WorkspaceAccess(
|
||||
authentication,
|
||||
survey.workspaceId,
|
||||
"readWrite",
|
||||
requestId,
|
||||
instance
|
||||
);
|
||||
|
||||
if (authResult instanceof Response) {
|
||||
return authResult;
|
||||
}
|
||||
|
||||
if (auditLog) {
|
||||
auditLog.targetId = survey.id;
|
||||
auditLog.organizationId = authResult.organizationId;
|
||||
auditLog.oldObject = survey;
|
||||
}
|
||||
|
||||
const deletedSurvey = await deleteSurvey(surveyId);
|
||||
|
||||
return successResponse(
|
||||
{
|
||||
id: deletedSurvey.id,
|
||||
},
|
||||
{ requestId }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ResourceNotFoundError) {
|
||||
log.warn({ errorCode: error.name, statusCode: 403 }, "Survey not found or not accessible");
|
||||
return problemForbidden(requestId, "You are not authorized to access this resource", instance);
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseError) {
|
||||
log.error({ error, statusCode: 500 }, "Database error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
|
||||
log.error({ error, statusCode: 500 }, "V3 survey delete unexpected error");
|
||||
return problemInternalError(requestId, "An unexpected error occurred.", instance);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -314,8 +314,8 @@ describe("GET /api/v3/surveys", () => {
|
||||
const res = await GET(req, {} as any);
|
||||
const body = await res.json();
|
||||
expect(body.data[0]).not.toHaveProperty("blocks");
|
||||
expect(body.data[0]).not.toHaveProperty("singleUse");
|
||||
expect(body.data[0]).not.toHaveProperty("_count");
|
||||
expect(body.data[0]).not.toHaveProperty("singleUse");
|
||||
expect(body.data[0].id).toBe("s1");
|
||||
expect(body.data[0].workspaceId).toBe("ws_1");
|
||||
});
|
||||
|
||||
@@ -93,17 +93,17 @@ describe("parseAndValidateJsonBody", () => {
|
||||
request,
|
||||
schema: z.object({
|
||||
finished: z.boolean(),
|
||||
environmentId: z.string(),
|
||||
workspaceId: z.string(),
|
||||
}),
|
||||
buildInput: (jsonInput) => ({
|
||||
...(jsonInput as Record<string, unknown>),
|
||||
environmentId: "env_123",
|
||||
workspaceId: "ws_123",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
data: {
|
||||
environmentId: "env_123",
|
||||
workspaceId: "ws_123",
|
||||
finished: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -339,6 +339,56 @@ describe("API Response Utilities", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("conflictResponse", () => {
|
||||
test("should return a conflict response", () => {
|
||||
const message = "Resource already exists";
|
||||
const details = { field: "singleUseId" };
|
||||
const response = responses.conflictResponse(message, details);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "conflict",
|
||||
message,
|
||||
details,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle undefined details", () => {
|
||||
const message = "Resource already exists";
|
||||
const response = responses.conflictResponse(message);
|
||||
|
||||
expect(response.status).toBe(409);
|
||||
|
||||
return response.json().then((body) => {
|
||||
expect(body).toEqual({
|
||||
code: "conflict",
|
||||
message,
|
||||
details: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should include CORS headers when cors is true", () => {
|
||||
const message = "Resource already exists";
|
||||
const response = responses.conflictResponse(message, undefined, true);
|
||||
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("*");
|
||||
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("GET, POST, PUT, DELETE, OPTIONS");
|
||||
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type, Authorization");
|
||||
});
|
||||
|
||||
test("should use custom cache control header when provided", () => {
|
||||
const message = "Resource already exists";
|
||||
const customCache = "no-cache";
|
||||
const response = responses.conflictResponse(message, undefined, false, customCache);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe(customCache);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tooManyRequestsResponse", () => {
|
||||
test("should return a too many requests response", () => {
|
||||
const message = "Rate limit exceeded";
|
||||
|
||||
@@ -16,7 +16,8 @@ interface ApiErrorResponse {
|
||||
| "method_not_allowed"
|
||||
| "not_authenticated"
|
||||
| "forbidden"
|
||||
| "too_many_requests";
|
||||
| "too_many_requests"
|
||||
| "conflict";
|
||||
message: string;
|
||||
details: {
|
||||
[key: string]: string | string[] | number | number[] | boolean | boolean[];
|
||||
@@ -236,6 +237,30 @@ const internalServerErrorResponse = (
|
||||
);
|
||||
};
|
||||
|
||||
const conflictResponse = (
|
||||
message: string,
|
||||
details?: { [key: string]: string },
|
||||
cors: boolean = false,
|
||||
cache: string = "private, no-store"
|
||||
) => {
|
||||
const headers = {
|
||||
...(cors && corsHeaders),
|
||||
"Cache-Control": cache,
|
||||
};
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
code: "conflict",
|
||||
message,
|
||||
details: details || {},
|
||||
} as ApiErrorResponse,
|
||||
{
|
||||
status: 409,
|
||||
headers,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const tooManyRequestsResponse = (
|
||||
message: string,
|
||||
cors: boolean = false,
|
||||
@@ -270,4 +295,5 @@ export const responses = {
|
||||
successResponse,
|
||||
tooManyRequestsResponse,
|
||||
forbiddenResponse,
|
||||
conflictResponse,
|
||||
};
|
||||
|
||||
@@ -3,9 +3,16 @@ import { NextRequest } from "next/server";
|
||||
import { Mock, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TAuthenticationApiKey } from "@formbricks/types/auth";
|
||||
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import type { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
|
||||
import { responses } from "./response";
|
||||
|
||||
const AuthMethod = {
|
||||
ApiKey: "apiKey" as AuthenticationMethod,
|
||||
Session: "session" as AuthenticationMethod,
|
||||
Both: "both" as AuthenticationMethod,
|
||||
None: "none" as AuthenticationMethod,
|
||||
} as const;
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
__esModule: true,
|
||||
queueAuditEvent: vi.fn(),
|
||||
@@ -122,7 +129,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -198,7 +205,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -244,7 +251,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -318,7 +325,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -370,7 +377,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -425,7 +432,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
@@ -449,7 +456,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
@@ -473,6 +480,90 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("skips app rate limiting for Envoy-covered client routes", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ method: "POST", url: "/api/v1/client/env_123/storage" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyIPRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps app rate limiting for uncovered client routes", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ method: "GET", url: "/api/v2/client/env_123/environment" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("keeps app rate limiting for uncovered verbs on otherwise covered client paths", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const req = createMockRequest({ method: "PATCH", url: "/api/v1/client/env_123/environment" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyIPRateLimit).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
@@ -481,7 +572,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
@@ -504,7 +595,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.Session,
|
||||
authenticationMethod: AuthMethod.Session,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(getServerSession).mockResolvedValue(null);
|
||||
@@ -528,7 +619,36 @@ describe("withV1ApiWrapper", () => {
|
||||
expect(mockContextualLoggerError).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
test("keeps app rate limiting for uncovered session-authenticated management routes", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { getServerSession } = await import("next-auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthMethod.Both,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(getServerSession).mockResolvedValue({ user: { id: "user-1" } } as any);
|
||||
const rateLimitError = new Error("Rate limit exceeded");
|
||||
rateLimitError.message = "Rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ method: "POST", url: "https://api.test/api/v1/management/storage" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const customRateLimitConfig = { interval: 60, allowedPerInterval: 5, namespace: "storage:upload" };
|
||||
const wrapped = withV1ApiWrapper({ handler, customRateLimitConfig });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(applyRateLimit).toHaveBeenCalledWith(customRateLimitConfig, "user-1");
|
||||
});
|
||||
|
||||
test("skips app rate limiting for Envoy-covered API-key management routes", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
@@ -538,21 +658,22 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
const rateLimitError = new Error("Rate limit exceeded");
|
||||
rateLimitError.message = "Rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
vi.mocked(applyRateLimit).mockResolvedValue({ allowed: true });
|
||||
|
||||
const handler = vi.fn().mockResolvedValue({
|
||||
response: responses.successResponse({ data: "test" }),
|
||||
});
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
expect(res.status).toBe(200);
|
||||
expect(applyRateLimit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
@@ -566,7 +687,7 @@ describe("withV1ApiWrapper", () => {
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
authenticationMethod: AuthMethod.ApiKey,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ import {
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import {
|
||||
TEnvoyRateLimitAuthType,
|
||||
isRouteRateLimitedByEnvoy,
|
||||
} from "@/modules/core/rate-limit/envoy-rate-limit-coverage";
|
||||
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
import { TRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
|
||||
@@ -61,29 +65,58 @@ const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): P
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
};
|
||||
|
||||
const getEnvoyRateLimitAuthType = (
|
||||
authentication: TApiV1Authentication
|
||||
): TEnvoyRateLimitAuthType | "unknown" => {
|
||||
if (!authentication) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
if ("user" in authentication) {
|
||||
return "session";
|
||||
}
|
||||
|
||||
if ("apiKeyId" in authentication) {
|
||||
return "apiKey";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
req: NextRequest,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
): Promise<Response | null> => {
|
||||
const authType = getEnvoyRateLimitAuthType(authentication);
|
||||
|
||||
if (authType === "unknown") {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
}
|
||||
|
||||
const isEnvoyManagedRateLimit = isRouteRateLimitedByEnvoy({
|
||||
pathname: req.nextUrl.pathname,
|
||||
method: req.method,
|
||||
authType,
|
||||
});
|
||||
|
||||
try {
|
||||
if (authentication) {
|
||||
if (authentication && !isEnvoyManagedRateLimit) {
|
||||
if ("user" in authentication) {
|
||||
// Session-based authentication for integration routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.user.id);
|
||||
} else if ("apiKeyId" in authentication) {
|
||||
// API key authentication for general routes
|
||||
await applyRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.v1, authentication.apiKeyId);
|
||||
} else {
|
||||
logger.error({ authentication }, "Unknown authentication type");
|
||||
return responses.internalServerErrorResponse("Invalid authentication configuration");
|
||||
}
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
if (routeType === ApiV1RouteTypeEnum.Client && !isEnvoyManagedRateLimit) {
|
||||
await applyClientRateLimit(customRateLimitConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -286,7 +319,12 @@ export const withV1ApiWrapper = <TResult extends { response: Response; error?: u
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
||||
const rateLimitResponse = await handleRateLimiting(
|
||||
req,
|
||||
authentication,
|
||||
routeType,
|
||||
customRateLimitConfig
|
||||
);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,9 @@ import type { TFunction } from "i18next";
|
||||
import type { TSurveyBlock, TSurveyBlockLogic } from "@formbricks/types/surveys/blocks";
|
||||
import type {
|
||||
TSurveyCTAElement,
|
||||
TSurveyCesElement,
|
||||
TSurveyConsentElement,
|
||||
TSurveyCsatElement,
|
||||
TSurveyElement,
|
||||
TSurveyMultipleChoiceElement,
|
||||
TSurveyNPSElement,
|
||||
@@ -96,7 +98,8 @@ export const buildOpenTextElement = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const buildRatingElement = ({
|
||||
const buildScaleElement = <T extends TSurveyRatingElement | TSurveyCsatElement | TSurveyCesElement>({
|
||||
type,
|
||||
id,
|
||||
headline,
|
||||
subheader,
|
||||
@@ -107,6 +110,32 @@ export const buildRatingElement = ({
|
||||
required,
|
||||
isColorCodingEnabled = false,
|
||||
}: {
|
||||
type: T["type"];
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale: T["scale"];
|
||||
range: T["range"];
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): T => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
} as T;
|
||||
};
|
||||
|
||||
export const buildRatingElement = (params: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale: TSurveyRatingElement["scale"];
|
||||
@@ -116,20 +145,8 @@ export const buildRatingElement = ({
|
||||
subheader?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): TSurveyRatingElement => {
|
||||
return {
|
||||
id: id ?? createId(),
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
subheader: subheader ? createI18nString(subheader, []) : undefined,
|
||||
headline: createI18nString(headline, []),
|
||||
scale,
|
||||
range,
|
||||
required: required ?? false,
|
||||
isColorCodingEnabled,
|
||||
lowerLabel: lowerLabel ? createI18nString(lowerLabel, []) : undefined,
|
||||
upperLabel: upperLabel ? createI18nString(upperLabel, []) : undefined,
|
||||
};
|
||||
};
|
||||
}): TSurveyRatingElement =>
|
||||
buildScaleElement<TSurveyRatingElement>({ ...params, type: TSurveyElementTypeEnum.Rating });
|
||||
|
||||
export const buildConsentElement = ({
|
||||
id,
|
||||
@@ -212,6 +229,38 @@ export const buildNPSElement = ({
|
||||
};
|
||||
};
|
||||
|
||||
export const buildCsatElement = ({
|
||||
scale = "smiley",
|
||||
...params
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale?: TSurveyCsatElement["scale"];
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): TSurveyCsatElement =>
|
||||
buildScaleElement<TSurveyCsatElement>({ ...params, scale, range: 5, type: TSurveyElementTypeEnum.CSAT });
|
||||
|
||||
export const buildCesElement = ({
|
||||
scale = "number",
|
||||
range = 5,
|
||||
...params
|
||||
}: {
|
||||
id?: string;
|
||||
headline: string;
|
||||
scale?: TSurveyCesElement["scale"];
|
||||
range?: TSurveyCesElement["range"];
|
||||
lowerLabel?: string;
|
||||
upperLabel?: string;
|
||||
subheader?: string;
|
||||
required?: boolean;
|
||||
isColorCodingEnabled?: boolean;
|
||||
}): TSurveyCesElement =>
|
||||
buildScaleElement<TSurveyCesElement>({ ...params, scale, range, type: TSurveyElementTypeEnum.CES });
|
||||
|
||||
// Helper function to create block-level jump logic based on operator
|
||||
export const createBlockJumpLogic = (
|
||||
sourceElementId: string,
|
||||
|
||||
@@ -30,6 +30,8 @@ const conditionOptions: Record<string, string[]> = {
|
||||
multipleChoiceMulti: ["Includes all", "Includes either"],
|
||||
nps: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped", "Includes either"],
|
||||
rating: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
|
||||
csat: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
|
||||
ces: ["Is equal to", "Is less than", "Is more than", "Submitted", "Skipped"],
|
||||
cta: ["is"],
|
||||
tags: ["is"],
|
||||
languages: ["Equals", "Not equals"],
|
||||
@@ -45,6 +47,8 @@ const filterOptions: Record<string, string[]> = {
|
||||
openText: ["Filled out", "Skipped"],
|
||||
rating: ["1", "2", "3", "4", "5"],
|
||||
nps: ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"],
|
||||
csat: ["1", "2", "3", "4", "5"],
|
||||
ces: ["1", "2", "3", "4", "5", "6", "7"],
|
||||
cta: ["Clicked", "Dismissed"],
|
||||
tags: ["Applied", "Not applied"],
|
||||
consent: ["Accepted", "Dismissed"],
|
||||
@@ -436,6 +440,8 @@ const processElementFilters = (
|
||||
break;
|
||||
case TSurveyElementTypeEnum.NPS:
|
||||
case TSurveyElementTypeEnum.Rating:
|
||||
case TSurveyElementTypeEnum.CSAT:
|
||||
case TSurveyElementTypeEnum.CES:
|
||||
processNPSRatingFilter(filterType, elementId, filters);
|
||||
break;
|
||||
case TSurveyElementTypeEnum.CTA:
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { TTemplate } from "@formbricks/types/templates";
|
||||
import {
|
||||
buildBlock,
|
||||
buildCTAElement,
|
||||
buildCesElement,
|
||||
buildConsentElement,
|
||||
buildCsatElement,
|
||||
buildMultipleChoiceElement,
|
||||
buildNPSElement,
|
||||
buildOpenTextElement,
|
||||
@@ -971,13 +973,13 @@ const improveTrialConversion = (t: TFunction): TTemplate => {
|
||||
elements: [
|
||||
buildOpenTextElement({
|
||||
id: reusableElementIds[2],
|
||||
headline: t("templates.improve_trial_conversion_question_2_headline"),
|
||||
headline: t("templates.improve_trial_conversion_question_3_headline"),
|
||||
required: true,
|
||||
inputType: "text",
|
||||
}),
|
||||
],
|
||||
logic: [createBlockJumpLogic(reusableElementIds[2], block6Id, "isSubmitted")],
|
||||
buttonLabel: t("templates.improve_trial_conversion_question_2_button_label"),
|
||||
buttonLabel: t("templates.improve_trial_conversion_question_3_button_label"),
|
||||
t,
|
||||
}),
|
||||
buildBlock({
|
||||
@@ -1319,8 +1321,7 @@ const employeeSatisfaction = (t: TFunction): TTemplate => {
|
||||
buildBlock({
|
||||
name: t("templates.block_1"),
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
range: 5,
|
||||
buildCsatElement({
|
||||
scale: "star",
|
||||
headline: t("templates.employee_satisfaction_question_1_headline"),
|
||||
required: true,
|
||||
@@ -1647,14 +1648,14 @@ const identifyCustomerGoals = (t: TFunction): TTemplate => {
|
||||
elements: [
|
||||
buildMultipleChoiceElement({
|
||||
type: TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
headline: "What's your primary goal for using $[workspaceName]?",
|
||||
headline: t("templates.identify_customer_goals_question_1_headline"),
|
||||
required: true,
|
||||
shuffleOption: "none",
|
||||
choices: [
|
||||
"Understand my user base deeply",
|
||||
"Identify upselling opportunities",
|
||||
"Build the best possible product",
|
||||
"Rule the world to make everyone breakfast brussels sprouts.",
|
||||
t("templates.identify_customer_goals_question_1_choice_1"),
|
||||
t("templates.identify_customer_goals_question_1_choice_2"),
|
||||
t("templates.identify_customer_goals_question_1_choice_3"),
|
||||
t("templates.identify_customer_goals_question_1_choice_4"),
|
||||
],
|
||||
}),
|
||||
],
|
||||
@@ -2723,7 +2724,7 @@ const customerEffortScore = (t: TFunction): TTemplate => {
|
||||
buildBlock({
|
||||
name: t("templates.block_1"),
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
buildCesElement({
|
||||
range: 5,
|
||||
scale: "number",
|
||||
headline: t("templates.customer_effort_score_question_1_headline"),
|
||||
@@ -3828,9 +3829,8 @@ const improveNewsletterContent = (t: TFunction): TTemplate => {
|
||||
buildBlock({
|
||||
name: t("templates.block_1"),
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
buildCsatElement({
|
||||
id: reusableElementIds[0],
|
||||
range: 5,
|
||||
scale: "smiley",
|
||||
headline: t("templates.improve_newsletter_content_question_1_headline"),
|
||||
required: true,
|
||||
@@ -4409,8 +4409,7 @@ const longTermRetentionCheckIn = (t: TFunction): TTemplate => {
|
||||
buildBlock({
|
||||
name: t("templates.block_9"),
|
||||
elements: [
|
||||
buildRatingElement({
|
||||
range: 5,
|
||||
buildCsatElement({
|
||||
scale: "smiley",
|
||||
headline: t("templates.long_term_retention_check_in_question_9_headline"),
|
||||
required: true,
|
||||
@@ -4825,6 +4824,8 @@ export const previewSurvey = (workspaceName: string, t: TFunction): TSurvey => {
|
||||
workspaceId: "cmnh38nzx00003b6r3svd9pv2",
|
||||
createdBy: "cltwumfbz0000echxysz6ptvq",
|
||||
status: "inProgress" as const,
|
||||
publishOn: null,
|
||||
closeOn: null,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
headline: createI18nString(t("templates.preview_survey_welcome_card_headline"), []),
|
||||
|
||||
@@ -121,13 +121,10 @@ export const DELETE = async (
|
||||
: responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
if (authResult.ok) {
|
||||
// Rate limiting for apiKey DELETE is enforced by Envoy in v5 — see envoy-rate-limit-coverage.ts
|
||||
if (authResult.ok && authResult.data.authType !== "apiKey") {
|
||||
try {
|
||||
if (authResult.data.authType === "apiKey") {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.apiKeyId);
|
||||
} else {
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
|
||||
}
|
||||
await applyRateLimit(rateLimitConfigs.storage.delete, authResult.data.userId);
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(
|
||||
error instanceof Error ? error.message : "Unknown error occurred"
|
||||
@@ -142,20 +139,20 @@ export const DELETE = async (
|
||||
idParam
|
||||
);
|
||||
|
||||
const isSuccess = deleteResult.ok;
|
||||
if (!deleteResult.ok) {
|
||||
const { error } = deleteResult;
|
||||
|
||||
if (!isSuccess) {
|
||||
logger.error({ error: deleteResult.error }, "Error deleting file");
|
||||
logger.error({ error }, "Error deleting file");
|
||||
|
||||
await logFileDeletion({
|
||||
failureReason: deleteResult.error.code,
|
||||
failureReason: error.code,
|
||||
accessType,
|
||||
userId: session?.user?.id,
|
||||
workspaceId: resolved.workspaceId,
|
||||
apiUrl: request.url,
|
||||
});
|
||||
|
||||
const errorResponse = getErrorResponseFromStorageError(deleteResult.error, { fileName });
|
||||
const errorResponse = getErrorResponseFromStorageError(error, { fileName });
|
||||
return errorResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"ro-RO",
|
||||
"ru-RU",
|
||||
"sv-SE",
|
||||
"tr-TR",
|
||||
"zh-Hans-CN",
|
||||
"zh-Hant-TW"
|
||||
]
|
||||
|
||||
+389
-39
@@ -98,6 +98,8 @@ checksums:
|
||||
common/activity: 1948763de8e531483a798b68195e297e
|
||||
common/add: 87c4a663507f2bcbbf79934af8164e13
|
||||
common/add_action: 66fefc4dd6a7b939c2224272cf0d2669
|
||||
common/add_charts: c377a42e165e8ab67bfbb8ad72026dd8
|
||||
common/add_existing_chart_description: b1292a1d6df2e03ad7b399689312c37f
|
||||
common/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
|
||||
common/add_logo: c8665aa9afd0d5a13528bdc96daefa53
|
||||
common/add_member: 11979625770516ca287e929381778e02
|
||||
@@ -109,6 +111,7 @@ checksums:
|
||||
common/allow: 3e39cc5940255e6bff0fea95c817dd43
|
||||
common/allow_users_to_exit_by_clicking_outside_the_survey: 1c09db6e85214f1b1c3d4774c4c5cd56
|
||||
common/an_unknown_error_occurred_while_deleting_table_items: 06be3fd128aeb51eed4fba9a079ecee2
|
||||
common/analysis: 409bac6215382c47e59f5039cc4cdcdd
|
||||
common/and: dc75b95c804b16dc617a5f16f7393bca
|
||||
common/anonymous: 77b5222e710cc1dae073dae32309f8ed
|
||||
common/api_keys: f961b547cd312cc8b9b79f0c9e0b2cc3
|
||||
@@ -127,6 +130,9 @@ checksums:
|
||||
common/centered_modal: 982ff411cb7e91e30300c2ed56b7e507
|
||||
common/change_organization: 3b2c873962509445ff2cb8cde5ad913b
|
||||
common/change_workspace: 489cbcf7eef9b9b960e426fbf4da318f
|
||||
common/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
|
||||
common/charts: 1da4564d89264c89de4ed28d7451b43e
|
||||
common/choice_n: a6965b8fb3e479e94175b3826839d9ae
|
||||
common/choices: 8a7a77a71ec6eebc363c5dc0f8490a4d
|
||||
common/choose_organization: a8f5db68012323bfbb1a0ad0fb194603
|
||||
common/choose_workspace: f9ed22d76c69cc75aa56cf3da3fa6320
|
||||
@@ -139,8 +145,9 @@ checksums:
|
||||
common/close: 2c2e22f8424a1031de89063bd0022e16
|
||||
common/code: 343bc5386149b97cece2b093c39034b2
|
||||
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
|
||||
common/column_n: b98315f0e504fad7e784d77f153a7d9d
|
||||
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
common/configuration: 923ec0502721489202f6222dd4107163
|
||||
common/configuration: e3ab18ebb36c218cd4897c620f5809ac
|
||||
common/confirm: 90930b51154032f119fa75c1bd422d8b
|
||||
common/connect: 8778ee245078a8be4a2ce855c8c56edc
|
||||
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
|
||||
@@ -159,6 +166,7 @@ checksums:
|
||||
common/count_questions: a7a34376a01eda781381fe7544541293
|
||||
common/count_responses: 437e022825c7a08481d8f7e56926742d
|
||||
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
|
||||
common/create: 757ccd28dd533ff3a933355273c1e32a
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -168,6 +176,8 @@ checksums:
|
||||
common/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/dashboard: c9380ea68c8c76ea451bd9613329a07c
|
||||
common/dashboards: 4bc47e48559a6b688684dcb7ac4babc9
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
@@ -191,10 +201,10 @@ checksums:
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/elements: 8cb054d952b341e5965284860d532bc7
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/enable: 463972a7a95f50f3105d09b92508f2cd
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
||||
@@ -205,6 +215,8 @@ checksums:
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
|
||||
common/field_placeholder: 1fedb1aab1a4d42ad49ddece6d8df372
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
@@ -216,10 +228,13 @@ checksums:
|
||||
common/generate: 0345bf322c191e70d01fd6607ec5c2f8
|
||||
common/go_back: b917ea82facb90c88c523b255d29f84b
|
||||
common/go_to_dashboard: a6efa97d25e36fedc0af794f6ba610f2
|
||||
common/headline: 0023cbe059bbadcc77312825cbbce5ac
|
||||
common/hidden: fa290c6ada5869d744ed35e9cca64699
|
||||
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
||||
common/hide: a6088b934651055bb27314d111be510b
|
||||
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
||||
common/html: f750870203043349d570d8f5865ca0f8
|
||||
common/id: c8886d38aeea2ed5f785aba4fc96784b
|
||||
common/image: 048ba7a239de0fbd883ade8558415830
|
||||
common/images: 9305827c28694866f49db42b4c51831f
|
||||
@@ -265,16 +280,18 @@ checksums:
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
|
||||
common/months: da74749fbe80394fa0f72973d7b0964a
|
||||
common/more_options: 53d90eae6a9b0243b5bc043b3d9de169
|
||||
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
||||
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
||||
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
||||
common/my_product: ad022177062f9ef6e9acf33b13e889aa
|
||||
common/name: 9368b5a047572b6051f334af5aa76819
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no: 8c708225830b06df2d1141c536f2a0d6
|
||||
common/no_actions_found: 4d92b789eb121fc76cd6868136dcbcd4
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_changes: 17709e3e2fbd133ddb8b3291d13de7f6
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
|
||||
common/no_overlay: 03cde9e91f08e4dd539d788e1e01407f
|
||||
@@ -282,6 +299,7 @@ checksums:
|
||||
common/no_result_found: fedddbc0149972ea072a9e063198a16d
|
||||
common/no_results: 0e9b73265c6542240f5a3bf6b43e9280
|
||||
common/no_surveys_found: 7b74706fe4f4aacd7d858e19e444fe85
|
||||
common/no_text_found: 27350f35bdd57b3701c7ec578a1a0e11
|
||||
common/none_of_the_above: e007f0b1e046d5ddbbcfbd87940456ee
|
||||
common/not_authenticated: fed6c62208524ea6782b5f9c07a95a4f
|
||||
common/not_authorized: 4be80383fe1a6f52c61138f1aa8d01d4
|
||||
@@ -296,6 +314,7 @@ checksums:
|
||||
common/on: 1929bcf2fba8003c043b446a851bcb4f
|
||||
common/only_one_file_allowed: 171be177f2e96c4bb4c4a47b3bf6c8c9
|
||||
common/only_owners_managers_and_manage_access_members_can_perform_this_action: 3c16fc506e871935f6183793e73b6709
|
||||
common/open_options: a4578c0afbfdf4a76d5952a53085b72a
|
||||
common/option_id: ed21d97b8ab035ba89fb3f5f073229bd
|
||||
common/option_ids: e68c25215ce81ea7ad82ff7be0a0bf2d
|
||||
common/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
@@ -305,7 +324,7 @@ checksums:
|
||||
common/organization_settings: 11528aa89ae9935e55dcb54478058775
|
||||
common/other: 79acaa6cd481262bea4e743a422529d2
|
||||
common/other_filters: 20b09213c131db47eb8b23e72d0c4bea
|
||||
common/others: 39160224ce0e35eb4eb252c997edf4d8
|
||||
common/other_placeholder: f3a0fa2eaaf75aa92b290449c928c081
|
||||
common/overlay_color: 4b72073285d13fff93d094aabffe05ac
|
||||
common/overview: 30c54e4dc4ce599b87d94be34a8617f5
|
||||
common/password: 223a61cf906ab9c40d22612c588dff48
|
||||
@@ -323,10 +342,8 @@ checksums:
|
||||
common/please_upgrade_your_plan: 03d54a21ecd27723c72a13644837e5ed
|
||||
common/powered_by_formbricks: 1c3e19894583292bfaf686cac84a4960
|
||||
common/preview: 3173ee1f0f1d4e50665ca4a84c38e15d
|
||||
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
|
||||
common/privacy: 7459744a63ef8af4e517a09024bd7c08
|
||||
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
|
||||
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
|
||||
common/profile: d7878693f91303a438852d617f6d35df
|
||||
common/profile_id: 0ef1286cce9d47b148e9a09deccb6655
|
||||
common/progress: dd0200d5849ebb7d64c15098ae91d229
|
||||
@@ -338,6 +355,7 @@ checksums:
|
||||
common/quotas_description: a2caa44fa74664b3b6007e813f31a754
|
||||
common/read_docs: d06513c266fdd9056e0500eab838ebac
|
||||
common/recipients: f90e7f266be3f5a724858f21a9fd855e
|
||||
common/refresh: c0aec3f31be4c984bae9a482572d2857
|
||||
common/remove: dba2fe5fe9f83f8078c687f28cba4b52
|
||||
common/remove_from_team: 69bcc7a1001c3017f9de578ee22cffd6
|
||||
common/reorder_and_hide_columns: a5e3d7c0c7ef879211d05a37be1c5069
|
||||
@@ -349,14 +367,19 @@ checksums:
|
||||
common/response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
|
||||
common/restart: bab6232e89f24e3129f8e48268739d5b
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/role: 53743bbb6ca938f5b893552e839d067f
|
||||
common/row_n: f90f7018a69f2d7025ad99a90bd23dc9
|
||||
common/saas: f01686245bcfb35a3590ab56db677bdb
|
||||
common/sales: 38758eb50094cd8190a71fe67be4d647
|
||||
common/save: f7a2929f33bc420195e59ac5a8bcd454
|
||||
common/save_as_draft: b1b38812110113627d141db981fb1b12
|
||||
common/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
common/save_without_scheduling: c6595873d611e6d1786fb15281158467
|
||||
common/saving: 27ad05746d65e2f3f17d327eb181725d
|
||||
common/scheduled: 1283929a2810dcf6110765f387dc118e
|
||||
common/search: 49dd6c21604b5e8d4153ff1aff2177e1
|
||||
common/search_charts: 51c3934f12f050fb2476d62da335a65c
|
||||
common/security: 4b34923fef858a2b9a4a914c3e822889
|
||||
common/segment: e8908115453de180bbda7478ba4c2d50
|
||||
common/segments: 271db72d5b973fbc5fadab216177eaae
|
||||
@@ -388,6 +411,7 @@ checksums:
|
||||
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
|
||||
common/string: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
|
||||
common/subheader: 73a37d57cb9807e574a42bd0c7e334ed
|
||||
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
|
||||
common/summary: 13eb7b8a239fb4702dfdaee69100a220
|
||||
common/survey: b659d270a53dada994d926e0cc6e9a54
|
||||
@@ -396,6 +420,7 @@ checksums:
|
||||
common/survey_languages: 93e4a10ab190e6b1e1f7fe5f702df249
|
||||
common/survey_live: d1f370505c67509e7b2759952daba20d
|
||||
common/survey_paused: c770d174d6b57e8425a54906a09c8b39
|
||||
common/survey_scheduled: 704c5e76b90ea2972ad6cae50f68dcdd
|
||||
common/survey_type: 417fcfecf8eaedefc4f11172426811f9
|
||||
common/surveys: 33f68ad4111b32a6361beb9d5c184533
|
||||
common/table_items_deleted_successfully: 46f29d20b26ecfbaf34d4e7291e88b05
|
||||
@@ -420,6 +445,7 @@ checksums:
|
||||
common/trial_one_day_remaining: 2d64d39fca9589c4865357817bcc24d5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unify: bdb518a1e62f51049ccc4366b909fb0a
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
@@ -437,6 +463,7 @@ checksums:
|
||||
common/variables: ffd3eec5497af36d7b4e4185bad1313a
|
||||
common/verified_email: d4a9e5e47d622c6ef2fede44233076c7
|
||||
common/video: 8050c90e4289b105a0780f0fdda6ff66
|
||||
common/view: 36a9b5e3dc153c036d320460d72a03c3
|
||||
common/warning: 6618da2c7e5e93bb4ea0e16d29ab8c4c
|
||||
common/we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable: f29f2e0286195dab170b9806bcd74fc9
|
||||
common/webhook: 70f95b2c27f2c3840b500fcaf79ee83c
|
||||
@@ -455,7 +482,7 @@ checksums:
|
||||
common/workspace_name_placeholder: 8a9e30ab01666af13c44a73b82c37ec1
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/yes: ec580fd11a45779b039466f1e35eed2a
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
common/you_have_reached_your_limit_of_workspace_limit: 506a6ee315d9754da7ea26929bc40f52
|
||||
@@ -471,11 +498,11 @@ checksums:
|
||||
emails/email_footer_text_2: 6fd719fe916a7155e1b0b88a72420717
|
||||
emails/email_template_text_1: 86b06d249ef069e0fe2457fe3889c416
|
||||
emails/embed_survey_preview_email_didnt_request: 895b4463965beac0df080ccdef847824
|
||||
emails/embed_survey_preview_email_environment_id: 4dc6803f9b79c8a4be35a4126a582331
|
||||
emails/embed_survey_preview_email_fight_spam: 71f345df8d483d5b6c2c6dbab458d0b2
|
||||
emails/embed_survey_preview_email_heading: 2a1982b3aeb91476cefb62554031394a
|
||||
emails/embed_survey_preview_email_subject: 3240ad34aac02860909e10187745070e
|
||||
emails/embed_survey_preview_email_text: 2eceb6fdedef104db8cfad4de7fdb9aa
|
||||
emails/embed_survey_preview_email_workspace_id: bafef925e1b57b52a69844fdf47aac3c
|
||||
emails/forgot_password_email_change_password: fe6d4ba303b82f4833b3293f0c4e88c0
|
||||
emails/forgot_password_email_did_not_request: 79d35c3800e23e9d4c95bf33f250104f
|
||||
emails/forgot_password_email_heading: fe6d4ba303b82f4833b3293f0c4e88c0
|
||||
@@ -562,6 +589,7 @@ checksums:
|
||||
s/question_preview: 9d8fbc0150fc10ba851beba2d4f4d9f3
|
||||
s/response_already_received: 8e7b1a7d6e01a1939bca95285af77a69
|
||||
s/response_submitted: d5df62e1db6012bd1283126a8dd7bad6
|
||||
s/scheduled: 0ec63111fd5efe2ac240912105c5f518
|
||||
s/survey_already_answered_heading: 4783c9c36ad0d4f8eec5dfeb14a04545
|
||||
s/survey_already_answered_subheading: 40cfe7e8680cd4fbb7318a05d3491c78
|
||||
s/survey_sent_to: 192c2b0d27e01c35953b851d3875722e
|
||||
@@ -687,6 +715,10 @@ checksums:
|
||||
templates/career_development_survey_question_6_choice_6: 79acaa6cd481262bea4e743a422529d2
|
||||
templates/career_development_survey_question_6_headline: 88d2a87cbf2ec21882798890990c2225
|
||||
templates/career_development_survey_question_6_subheader: b9b478e967930358b0c74324a7c18fc8
|
||||
templates/ces: 49fc8d0ae7b82f3e7d49922ada7ab7a1
|
||||
templates/ces_description: 66f4aaa7e76fd87d19c4ec3bf71481e0
|
||||
templates/ces_lower_label: c2f05d3610d8879ae503a61d49e32e80
|
||||
templates/ces_upper_label: b88eaddaea17a4f285209c2529a9b8f8
|
||||
templates/cess_survey_name: dd706043a56d66f2895cad743935c5b4
|
||||
templates/cess_survey_question_1_headline: 70115a7960746a05acef03f815652fc3
|
||||
templates/cess_survey_question_1_lower_label: 586eedbc7b53319775e42c7cd4cef4de
|
||||
@@ -750,7 +782,9 @@ checksums:
|
||||
templates/consent_description: d76e48fb1e8c291b51e783eaf7fc910d
|
||||
templates/contact_info: 73913230e8988f5f423e54e0fd43f368
|
||||
templates/contact_info_description: 0e8962e628bb0a072a4217ae172db43b
|
||||
templates/csat_description: 4dd35d7fecfa9fdf47765c7108c3d535
|
||||
templates/csat: 6864fe0caad3b052a4ec0837e7b71cee
|
||||
templates/csat_description: 0e64d5594f961e5070a95f715594549e
|
||||
templates/csat_lower_label: 206c68e770b90abd737c8c4cb99aa695
|
||||
templates/csat_name: f216066cef52693bbaa842a3305377c7
|
||||
templates/csat_question_10_headline: b6a9ca9c6c20dced146d817c9a1e9be7
|
||||
templates/csat_question_10_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
@@ -826,6 +860,7 @@ checksums:
|
||||
templates/csat_survey_question_2_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
|
||||
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/csat_upper_label: a3a49eb9cc86972bce6dc41a107f472d
|
||||
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
|
||||
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
|
||||
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
|
||||
@@ -991,6 +1026,11 @@ checksums:
|
||||
templates/gauge_feature_satisfaction_question_2_headline: 0fcbefbfcf5c21e42de8a36cb2cad854
|
||||
templates/identify_customer_goals_description: c30d06df9e5c76334e4c3d470ee6e4d8
|
||||
templates/identify_customer_goals_name: f8123dbfa22e169517a811fae7496595
|
||||
templates/identify_customer_goals_question_1_choice_1: a6803cfbdbd6208eedf5c691f9e106a5
|
||||
templates/identify_customer_goals_question_1_choice_2: 7461749517d62030ec2e3915cf1d223b
|
||||
templates/identify_customer_goals_question_1_choice_3: 725eb3ee0d4f2d229fcf588c21e66a86
|
||||
templates/identify_customer_goals_question_1_choice_4: 3985521036afaf1cbd2bdc7a4d86d351
|
||||
templates/identify_customer_goals_question_1_headline: 45a7347cf3ae2d498a30ca1266898cf8
|
||||
templates/identify_sign_up_barriers_description: 5b2fbee8c425d7a4d0706ec3628cea11
|
||||
templates/identify_sign_up_barriers_name: 3bbc5352dfa7a9c237bc2c6b21b608dd
|
||||
templates/identify_sign_up_barriers_question_1_button_label: 080fd22c580f56ffdcea6c3d60448b84
|
||||
@@ -1065,6 +1105,8 @@ checksums:
|
||||
templates/improve_trial_conversion_question_1_subheader: 67c7047ba2365d461df14dbed3f9506d
|
||||
templates/improve_trial_conversion_question_2_button_label: 89ddbcf710eba274963494f312bdc8a9
|
||||
templates/improve_trial_conversion_question_2_headline: 54324cd652667183dd3cf647ba72dd07
|
||||
templates/improve_trial_conversion_question_3_button_label: 89ddbcf710eba274963494f312bdc8a9
|
||||
templates/improve_trial_conversion_question_3_headline: 8dfe1f843c8de64de7e3fa619b961152
|
||||
templates/improve_trial_conversion_question_4_button_label: d94a6a11cfdf4ebde4c5332e585e2e96
|
||||
templates/improve_trial_conversion_question_4_headline: 9b07341f65574c4165086ec107cebb45
|
||||
templates/improve_trial_conversion_question_4_html: 8ce95691eeeae7ad61c4d2f867b918ca
|
||||
@@ -1532,7 +1574,6 @@ checksums:
|
||||
workspace/actions/this_action_will_be_triggered_when_the_page_is_loaded: 8d28f30cf56f50ea79aef8dd2b02185d
|
||||
workspace/actions/this_action_will_be_triggered_when_the_user_scrolls_50_percent_of_the_page: 60230ad129853377af1066e10f3b3c22
|
||||
workspace/actions/this_action_will_be_triggered_when_the_user_tries_to_leave_the_page: 504fa734659524933e3489823d820265
|
||||
workspace/actions/this_is_a_code_action_please_make_changes_in_your_code_base: 27e64863fdebca791ca129ac0c3da3d5
|
||||
workspace/actions/time_in_seconds: 822be76950e5f614ed23f52ba1d4825f
|
||||
workspace/actions/time_in_seconds_placeholder: e54a4c40e0c6b43fb2e97bd32cab8da8
|
||||
workspace/actions/time_in_seconds_with_unit: a743b7844c71ddad364a93872682ae9e
|
||||
@@ -1547,6 +1588,176 @@ checksums:
|
||||
workspace/actions/you_can_track_code_action_anywhere_in_your_app_using: 3c0bbf160b8ddbeef142403103b70554
|
||||
workspace/actions/your_survey_would_be_shown_on_this_url: 766fdeeb52d170c156af5d035a1f8c37
|
||||
workspace/actions/your_survey_would_not_be_shown: af44fe160f449ff9557ebe5d3686832d
|
||||
workspace/analysis/charts/OR: 0208d355f231c386b19390f0bea41b95
|
||||
workspace/analysis/charts/add_chart_to_dashboard: c2a517ada86cdda60e49bec655ca9a6d
|
||||
workspace/analysis/charts/add_chart_to_dashboard_description: 08980a1849757e9aec21fca5881c6be4
|
||||
workspace/analysis/charts/add_filter: ed5d8e9bfcb05cd1e10e4c403befbae6
|
||||
workspace/analysis/charts/add_to_dashboard: 9941c3d30895bb8e25ce8d4e03d33a08
|
||||
workspace/analysis/charts/advanced_chart_builder_config_prompt: c2fe2c1a076f27d3ae62a4db75474b0a
|
||||
workspace/analysis/charts/ai_query_placeholder: 24c3d18f514cb3a9953f04c3b04503a2
|
||||
workspace/analysis/charts/ai_query_section_description: 66d06342f29bf6658793403856521fd7
|
||||
workspace/analysis/charts/ai_query_section_title: c0e450a47af7c2a516b77f73cf54db1b
|
||||
workspace/analysis/charts/and_filter_logic: 53e8eb67a396fcb5e419bb4cbf0008df
|
||||
workspace/analysis/charts/apply_changes: ed3da8072dbd27dc0c959777cdcbebf3
|
||||
workspace/analysis/charts/chart: 6f4d9c56e45ceb8fc22d2f74454cd813
|
||||
workspace/analysis/charts/chart_added_to_dashboard: 7bc429ab605cb89a9232c26be008cc00
|
||||
workspace/analysis/charts/chart_builder_choose_chart_type: 1376de2dcafac573a2df9e4c007b0ec8
|
||||
workspace/analysis/charts/chart_data: 6739a9576b357a58d73ff0c9bf8db0e4
|
||||
workspace/analysis/charts/chart_data_tab: b7b46ab6ce9606032c8f81f6f6afbb9b
|
||||
workspace/analysis/charts/chart_deleted_successfully: 79148f471cd9acc2c8d0d033fb85437e
|
||||
workspace/analysis/charts/chart_deletion_error: 267eb65c168e726075d7cea678dd32e0
|
||||
workspace/analysis/charts/chart_duplicated_successfully: 755c4ce5bf533764d549a53c33e32165
|
||||
workspace/analysis/charts/chart_duplication_error: 90d7166c85188b52f821c9d9f53ff8c4
|
||||
workspace/analysis/charts/chart_name: cdb36e2f121a7b9c28298e15ab8218dc
|
||||
workspace/analysis/charts/chart_name_placeholder: 7370d4f88f27aea337ba1c36465c3f8b
|
||||
workspace/analysis/charts/chart_preview: 1b7faae244d31e43f758f50b94132413
|
||||
workspace/analysis/charts/chart_render_error: 01e9ece0c86a1fedf301afa0dbbf6aeb
|
||||
workspace/analysis/charts/chart_saved_successfully: 2489c853c0b36790e3592ac6ea31cc61
|
||||
workspace/analysis/charts/chart_type_area: 535754c6425f045f17e1dcb551840c93
|
||||
workspace/analysis/charts/chart_type_bar: c11d460595d3ddfe8efd67ac068574c5
|
||||
workspace/analysis/charts/chart_type_big_number: 9d17fb96241507c955dca25e143ae67a
|
||||
workspace/analysis/charts/chart_type_line: f42dd53238ed4d44def306a61d47d5c4
|
||||
workspace/analysis/charts/chart_type_not_supported: c25334de42fd6192ff8355158865a3e8
|
||||
workspace/analysis/charts/chart_type_pie: 068a797404233ccf68d07ad63af7b50c
|
||||
workspace/analysis/charts/chart_updated_successfully: a2c210523902c726aa1328bbeda0b357
|
||||
workspace/analysis/charts/configure_description: 2939321f78e4ffbc57b4259ddaddb09d
|
||||
workspace/analysis/charts/configure_title: ab767b11da1d386b98b3f634f79d3abe
|
||||
workspace/analysis/charts/configure_type_label: cd13e4b37fb2021af55903e7690a9856
|
||||
workspace/analysis/charts/contains: 06dd606c0a8f81f9a03b414e9ae89440
|
||||
workspace/analysis/charts/create_chart: 636969b904e88bef5c93e859dd8a1656
|
||||
workspace/analysis/charts/create_chart_description: b9680bd8905dea180fa59a86f61de34e
|
||||
workspace/analysis/charts/create_chart_with_ai: b0092b5616015a29dd51fbab49bcd4c4
|
||||
workspace/analysis/charts/custom_range: 99f4d72b64621406acc162cceeb1fed7
|
||||
workspace/analysis/charts/dashboard: c9380ea68c8c76ea451bd9613329a07c
|
||||
workspace/analysis/charts/dashboard_select_placeholder: 9b875f2f10050d650ae63be53fe0d4e8
|
||||
workspace/analysis/charts/data_label: b7b46ab6ce9606032c8f81f6f6afbb9b
|
||||
workspace/analysis/charts/data_source: c29cdd1967a3d1b1a39e91e14469b047
|
||||
workspace/analysis/charts/date_preset_last_30_days: a738894cfc5e592052f1e16787744568
|
||||
workspace/analysis/charts/date_preset_last_7_days: 3631df3109bfecfe358ba15dcf8bd6f5
|
||||
workspace/analysis/charts/date_preset_last_month: 848086395b28875c050d56e3933dae61
|
||||
workspace/analysis/charts/date_preset_this_month: 50845a38865204a97773c44dcd2ebb90
|
||||
workspace/analysis/charts/date_preset_this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
workspace/analysis/charts/date_preset_this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
workspace/analysis/charts/date_preset_today: 142173f9752e18e92109623a3ee68cad
|
||||
workspace/analysis/charts/date_preset_yesterday: eeb58908e68ff96c1b7e8f90e389afb7
|
||||
workspace/analysis/charts/date_range: 9b3aa5954144de586931f60ef9594e99
|
||||
workspace/analysis/charts/delete_chart_confirmation: f7fd7b0a08e81c9b392b08c9c1ad2147
|
||||
workspace/analysis/charts/dimensions: f09d837ac25f58986a769bd48ea15022
|
||||
workspace/analysis/charts/dimensions_toggle_description: 31eb28f3c83c04bbe37799758ca9f595
|
||||
workspace/analysis/charts/edit_chart_description: 822890e4b6068096e2fe8b7b78b4474f
|
||||
workspace/analysis/charts/edit_chart_title: fd3e7f8c53280bfad8f4034c055f4c71
|
||||
workspace/analysis/charts/enable_time_dimension: cfcf0af2d22bccd197319c07680c2cb8
|
||||
workspace/analysis/charts/end_date: acbea5a9fd7a6fadf5aa1b4f47188203
|
||||
workspace/analysis/charts/enter_a_name_for_your_chart: b6e992a23d0628136121ebf26eec4a50
|
||||
workspace/analysis/charts/enter_value: a4554ed67c02872e302b0042724f859d
|
||||
workspace/analysis/charts/equals: 264ec282f7f5b67da622cc37f2b57b8a
|
||||
workspace/analysis/charts/failed_to_add_chart_to_dashboard: 355a5606399edcbb3e6d0ba0b66f12a6
|
||||
workspace/analysis/charts/failed_to_execute_query: d1153133aa4cd3d1cd02e39942413168
|
||||
workspace/analysis/charts/failed_to_load_chart: abea098fbf8e728f95414d3ae8bb63a4
|
||||
workspace/analysis/charts/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
|
||||
workspace/analysis/charts/failed_to_save_chart: e237cf1a56a8f9ee30067fdb0757f7c5
|
||||
workspace/analysis/charts/field: cfd632297d7809a3539e90c9cd4728d9
|
||||
workspace/analysis/charts/field_label_average_score: 5b5aa7322549521d1e813b1c8312d443
|
||||
workspace/analysis/charts/field_label_collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/analysis/charts/field_label_count: 9c5848662eb8024ddf360f7e4001a968
|
||||
workspace/analysis/charts/field_label_detractor_count: eedb15bc383eb0f14d43043e6666c62a
|
||||
workspace/analysis/charts/field_label_emotion: eb3a31ead51b5c8a8d365d5f904e9206
|
||||
workspace/analysis/charts/field_label_field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/analysis/charts/field_label_nps_score: 9c8d0b0b460f9689bd66e81d45e0a2df
|
||||
workspace/analysis/charts/field_label_nps_value: cb7404025044400e3d7d5600f3133e4f
|
||||
workspace/analysis/charts/field_label_passive_count: ceb71da8d1382eb2097089dc3ecf76da
|
||||
workspace/analysis/charts/field_label_promoter_count: c393131a4bd3a25bf6b297beed20e34f
|
||||
workspace/analysis/charts/field_label_response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
workspace/analysis/charts/field_label_sentiment: 9ba5719c80c0136c2d0644217619aff6
|
||||
workspace/analysis/charts/field_label_source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/analysis/charts/field_label_source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/analysis/charts/field_label_topic: 7f542b783cd528f00f4f485e35b48dc1
|
||||
workspace/analysis/charts/field_label_user_identifier: b0174469c95038766744fb7e64005aec
|
||||
workspace/analysis/charts/filter_data: 05cc68ed2896feef60bbe3829cd9063d
|
||||
workspace/analysis/charts/filters: acf5accc113ff3c1992688058576732c
|
||||
workspace/analysis/charts/filters_toggle_description: ea18bdb212a6a85620125cab89a4b1c1
|
||||
workspace/analysis/charts/go_to_feedback_record_directories: 1aa0516beef8adbd330cffdcab8b521f
|
||||
workspace/analysis/charts/granularity: 9eb09aef092e7803ce4acb7965cbbaa9
|
||||
workspace/analysis/charts/granularity_day: 47648cd60fc313bc3f05b70357a1d675
|
||||
workspace/analysis/charts/granularity_hour: ec3113f22fc51d01f0c615c5496f8f87
|
||||
workspace/analysis/charts/granularity_month: ae7bef950efc406ff0980affabc1a64c
|
||||
workspace/analysis/charts/granularity_quarter: 7a68ec90d7c90b92b7bb873834a00381
|
||||
workspace/analysis/charts/granularity_week: 436fdd694160827dd6ea4644cdd0a8f8
|
||||
workspace/analysis/charts/granularity_year: ed86f5f60583f9d8ffdbeed306aa0ec7
|
||||
workspace/analysis/charts/greater_than: a4c18b3b45fcaf7c83bf489cf2b506d4
|
||||
workspace/analysis/charts/greater_than_or_equal: d453e26d136847560148168797fece51
|
||||
workspace/analysis/charts/group_by: 3f1cedea7783018ce83f2fab0051a738
|
||||
workspace/analysis/charts/group_by_description: a4a85baaca87c172023cbe87e620118b
|
||||
workspace/analysis/charts/group_data: 55c0035773d8c6b7f4d96363a61cda82
|
||||
workspace/analysis/charts/is_not_set: 906801489132487ef457652af4835142
|
||||
workspace/analysis/charts/is_set: 9850468156356f95884bbaf56b6687aa
|
||||
workspace/analysis/charts/less_than: fb41255dd44bb6de78617b078610c91b
|
||||
workspace/analysis/charts/less_than_or_equal: da4a2816aadf788d33efcdcc3c61802e
|
||||
workspace/analysis/charts/measures: b1e6cf0f356dda0052c4fef4ad4957a2
|
||||
workspace/analysis/charts/no_charts_found: d4a27d5b56e49ebdd38bf28791dbcc42
|
||||
workspace/analysis/charts/no_dashboards_available: f88389b6c5278cfc4d5b360031205dfe
|
||||
workspace/analysis/charts/no_dashboards_create_first: 28ded0d72247191eb23f6f77925df539
|
||||
workspace/analysis/charts/no_data_available: fe1d34a45e22b5611d255b84b2d67232
|
||||
workspace/analysis/charts/no_data_returned: 683acf7b4f3b32aa85fa26f1bb948d4f
|
||||
workspace/analysis/charts/no_data_returned_for_chart: b9ff6c85697c683f40b3d0c05eeb2046
|
||||
workspace/analysis/charts/no_data_source_available: 48179160e288de4a9e00f0bf110a5ced
|
||||
workspace/analysis/charts/no_grouping: e3a6943e61407600cae057e0833a482d
|
||||
workspace/analysis/charts/no_valid_data_to_display: d1ba2b0686520c0a2c62ee73daa1c9c9
|
||||
workspace/analysis/charts/not_contains: 5894f5474271b8902d7892e43500d227
|
||||
workspace/analysis/charts/not_equals: 427715f1ea349965c36f5c628784eb08
|
||||
workspace/analysis/charts/open_chart: 729a54bbc4bcb3f431865af5e5a50dd4
|
||||
workspace/analysis/charts/open_options: 2c6a35fec9b9d008e41728594bcd07d7
|
||||
workspace/analysis/charts/or_filter_logic: 0208d355f231c386b19390f0bea41b95
|
||||
workspace/analysis/charts/original: 7e55782bdf7cb49f5616b326c003c278
|
||||
workspace/analysis/charts/please_enter_chart_name: 9258b71b2cb09d22ffe33de1755e7309
|
||||
workspace/analysis/charts/please_enter_filter_values: ca79dfab463a3836863618fd92f82b3e
|
||||
workspace/analysis/charts/please_select_at_least_one_dimension: 32ea97a02bb6826947bb70389d1a6231
|
||||
workspace/analysis/charts/please_select_at_least_one_measure: d4163ede267f71ee65945f453e14ff7b
|
||||
workspace/analysis/charts/please_select_dashboard: 8f062db96f815ed8268584dd8d292fa6
|
||||
workspace/analysis/charts/predefined_measures: 7651141f62c991954edcff70899b2a8b
|
||||
workspace/analysis/charts/preset: a17bb0bf56f3326c9567be3ea896ee19
|
||||
workspace/analysis/charts/query_executed_successfully: 9d6f9dad526fcfe0161757c2d2fe2c69
|
||||
workspace/analysis/charts/reset_to_ai_suggestion: 51ced8dd7c0eea8b7fc4e08b35cfbf30
|
||||
workspace/analysis/charts/save_chart: 2e4505f7bf3d1c35b0b37b1e9d3dc566
|
||||
workspace/analysis/charts/save_chart_dialog_title: 2e4505f7bf3d1c35b0b37b1e9d3dc566
|
||||
workspace/analysis/charts/select_data_source: 983394bc0182b65ec68f713a46b97302
|
||||
workspace/analysis/charts/select_data_source_first: 82a02846de9d6351595c97a0929f3b9a
|
||||
workspace/analysis/charts/select_dimensions: 6d0d038d027ef9e641bf9b7700edac9f
|
||||
workspace/analysis/charts/select_field: 45665a44f7d5707506364f17f28db3bf
|
||||
workspace/analysis/charts/select_measures: c9f101aeb53bf0d4abdd652aaf60a1bf
|
||||
workspace/analysis/charts/select_preset: e68bad9a209a6ca35c62184f1f1d829c
|
||||
workspace/analysis/charts/showing_first_n_of: e9c1e76a46d0635f775a5b86bddbe1c3
|
||||
workspace/analysis/charts/start_date: 881de78c79b56f5ceb9b7103bf23cb2c
|
||||
workspace/analysis/charts/time_dimension: 5c967f2a6a875b00825068df5cb2ef84
|
||||
workspace/analysis/charts/time_dimension_title: 9353ce9a075a0cc8c3ba7dfa9ef19a8d
|
||||
workspace/analysis/charts/time_dimension_toggle_description: 77251d8b3b564390bad8b76f56905190
|
||||
workspace/analysis/dashboards/add_count_charts: b4ee1f29efce0bb380a060e0bc5d64fa
|
||||
workspace/analysis/dashboards/charts_add_failed: c4fda79ede798ab6747a989f083a0947
|
||||
workspace/analysis/dashboards/charts_add_partial_failure: b1a9fc6fe18ab20fe16c16e91a05c195
|
||||
workspace/analysis/dashboards/charts_added_to_dashboard: 917abab80231adf5af51e812352fc77b
|
||||
workspace/analysis/dashboards/charts_load_failed: 190bf9c13d3c3cf18126a263591d6757
|
||||
workspace/analysis/dashboards/create_dashboard: bedb308708fe9c576e161a2fa16d3439
|
||||
workspace/analysis/dashboards/create_dashboard_description: d29f60615f6d8c96cc4265541e75ec26
|
||||
workspace/analysis/dashboards/create_failed: 7b58f15568047a35220b3a47cc3b0f71
|
||||
workspace/analysis/dashboards/create_success: 1fa4dea7702ba03a8a3533295276ff1b
|
||||
workspace/analysis/dashboards/dashboard: c9380ea68c8c76ea451bd9613329a07c
|
||||
workspace/analysis/dashboards/dashboard_delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
|
||||
workspace/analysis/dashboards/dashboard_name: a2d344bc03f27706b42d7d6a8d0fc752
|
||||
workspace/analysis/dashboards/dashboard_name_placeholder: 02954eeb5671f1c00e3f69b47319916e
|
||||
workspace/analysis/dashboards/dashboard_name_required: 4a56c3ce1d73ad915815f5de4bcff566
|
||||
workspace/analysis/dashboards/dashboard_save_failed: 2b6c7be7947bc7ebb0389b71b5922ba6
|
||||
workspace/analysis/dashboards/dashboard_saved: 6eb27743b6b12d3d0a20b430319890b8
|
||||
workspace/analysis/dashboards/delete_confirmation: 468a0fb0e24a985cc47a778b50b334ba
|
||||
workspace/analysis/dashboards/delete_failed: b108acc28b1f9abcb544a358a958b54b
|
||||
workspace/analysis/dashboards/delete_success: 9d161634daab9ea9d17fbfb413eeeffa
|
||||
workspace/analysis/dashboards/duplicate_failed: 6ebaf8ad373b156f88f1ed79a5efd441
|
||||
workspace/analysis/dashboards/duplicate_success: 37cbb14143776d4c215432673e32ebd9
|
||||
workspace/analysis/dashboards/failed_to_load_chart_data: ea980a6d12b1b1efed90d991dd0dd0fd
|
||||
workspace/analysis/dashboards/no_charts_available_description: 796ed01bcb53f770e5f627002839dcb4
|
||||
workspace/analysis/dashboards/no_charts_to_add_message: ad4cec703aa7d59c407bbb021dce4273
|
||||
workspace/analysis/dashboards/no_dashboards_found: e049ec0356009c3a0aa2c729d916efc6
|
||||
workspace/analysis/dashboards/no_data_message: 464d50cf30281a5b6af2726846eb14b4
|
||||
workspace/analysis/dashboards/please_enter_name: b9211ed8a0882c0e0109beba48685d68
|
||||
workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
|
||||
workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
|
||||
workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
|
||||
@@ -1557,20 +1768,24 @@ checksums:
|
||||
workspace/api_keys/api_key_updated: 0e03754eb33742b4ee8d5fdad64c9b3f
|
||||
workspace/api_keys/delete_api_key_confirmation: b2f0342d4e55f0cb244fe121eeeb10a3
|
||||
workspace/api_keys/duplicate_access: 7ac7ac5ba755ce94e6fc81afa5a21997
|
||||
workspace/api_keys/duplicate_directory_access: e112b756627f0b5e2551451274c3781f
|
||||
workspace/api_keys/feedback_record_directory_access: 51babe735cb94388f68108555814b4f6
|
||||
workspace/api_keys/no_api_keys_yet: 58593ed9f7e507dcd7ca7fe069add599
|
||||
workspace/api_keys/no_env_permissions_found: 97ef49946f3ce15c2ad44dcfd2bce507
|
||||
workspace/api_keys/no_directory_permissions_found: ae55b273dd4c71d8431d64d99b39c59f
|
||||
workspace/api_keys/no_workspace_permissions_found: 1d719624828a9d3e433cdf6b387549f3
|
||||
workspace/api_keys/organization_access: 96a92fa907b15e0c0e47e33cac15be88
|
||||
workspace/api_keys/organization_access_description: 773dfeaf6ffbf34dd9a0a3d656a6d83c
|
||||
workspace/api_keys/permissions: 2160be68b1d6b6577e64634e9feba2ed
|
||||
workspace/api_keys/secret: f041e5eb96121c8b4f2b8af7e0f83a9b
|
||||
workspace/api_keys/unable_to_copy_api_key: 148506832e31d033fa3569ce292d2120
|
||||
workspace/api_keys/unable_to_delete_api_key: 1fd76d9a22c5f5f8c241c4891fca8295
|
||||
workspace/api_keys/unknown_directory: ed07f55f5dba1f451a45f2cf6e01c9a9
|
||||
workspace/api_keys/unknown_workspace: 4b0df2d07ebc9ab084158b1b9525ae5e
|
||||
workspace/api_keys/workspace_access: b38cb73197ef5f5fa6653b88c68aa0bd
|
||||
workspace/app-connection/app_connection: 778d2305e1a9c8efe91c2c7b4af37ae4
|
||||
workspace/app-connection/app_connection_description: dde226414bd2265cbd0daf6635efcfdd
|
||||
workspace/app-connection/cache_update_delay_description: 3368e4a8090b7684117a16c94f0c409c
|
||||
workspace/app-connection/cache_update_delay_title: 60e4a0fcfbd8850bddf29b5c3f59550c
|
||||
workspace/app-connection/environment_id: 49141af65970ea79e22ecedb97ceb2e4
|
||||
workspace/app-connection/environment_id_description: d611755139dbe9865f1436acf6f679be
|
||||
workspace/app-connection/formbricks_sdk_connected: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
workspace/app-connection/formbricks_sdk_not_connected: 557c534e665750978ba6edb0eacb428e
|
||||
workspace/app-connection/formbricks_sdk_not_connected_description: 4ddbacae084238bd0cefeded0fe9dbb9
|
||||
@@ -1579,10 +1794,12 @@ checksums:
|
||||
workspace/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
|
||||
workspace/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
|
||||
workspace/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
|
||||
workspace/app-connection/sdk_connection_details_description: 8e6d79678736819bf2f2940404ba5c3e
|
||||
workspace/app-connection/sdk_connection_details_description: 2d6824466039672fa002d72da95b4637
|
||||
workspace/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
|
||||
workspace/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
|
||||
workspace/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
|
||||
workspace/app-connection/workspace_id: 49141af65970ea79e22ecedb97ceb2e4
|
||||
workspace/app-connection/workspace_id_description: 77e5219be241e9973741f138787ccbb8
|
||||
workspace/connect/congrats: c2f5b597aabdf298cf9f0452863e2dc6
|
||||
workspace/connect/connection_successful_message: fa1f29883e15e8697c6c477bdf5cb645
|
||||
workspace/connect/do_it_later: ab4accfbe53d924ab3ffaf9ea78a75f3
|
||||
@@ -1609,10 +1826,10 @@ checksums:
|
||||
workspace/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
|
||||
workspace/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
|
||||
workspace/contacts/attributes_msg_attribute_type_validation_error: bd70f9773ae873240d4cdb26a662334c
|
||||
workspace/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
|
||||
workspace/contacts/attributes_msg_email_already_exists: 308cf739f7b98a6b2707acf3b658f220
|
||||
workspace/contacts/attributes_msg_email_or_userid_required: febc8b0cda4dd45d2c3cdb1ac2d45dcb
|
||||
workspace/contacts/attributes_msg_new_attribute_created: 5cba6158c4305c05104814ec1479267c
|
||||
workspace/contacts/attributes_msg_userid_already_exists: 9c695538befc152806c460f52a73821a
|
||||
workspace/contacts/attributes_msg_userid_already_exists: 94851fa7f17ffd0da323658dbf6bdd31
|
||||
workspace/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
workspace/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
workspace/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
@@ -1857,7 +2074,6 @@ checksums:
|
||||
workspace/languages/duplicate_language_or_language_id: 0e17e3794b24e2428ca6ffadae0d08f3
|
||||
workspace/languages/edit_languages: c9d36f6b28557cc7d54e87c37dc18fdd
|
||||
workspace/languages/identifier: 7d8ade6b85e96216bcd73adeeeeecd8c
|
||||
workspace/languages/incomplete_translations: d82908b5725f18f5849c7876ad497ebc
|
||||
workspace/languages/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
workspace/languages/language_deleted_successfully: 4a805d030491f3fe608d2371b0cfcd83
|
||||
workspace/languages/languages_updated_successfully: 60de474c99c5059c0458cddd0b016c15
|
||||
@@ -1868,7 +2084,6 @@ checksums:
|
||||
workspace/languages/remove_language: 1a64563b0f37109f97b78eddd493e381
|
||||
workspace/languages/remove_language_from_surveys_to_remove_it_from_workspace: 61bc96f9db31a29a649cc9ecd684bc39
|
||||
workspace/languages/search_items: b54b751c8b075200be579d6c8e58096b
|
||||
workspace/languages/translate: 59f9803b27e2030ba7323ed239116cf7
|
||||
workspace/look/add_background_color: 9be512ee1246e32d3958c56097d202d9
|
||||
workspace/look/add_background_color_description: adb6fcb392862b3d0e9420d9b5405ddb
|
||||
workspace/look/advanced_styling_field_border_radius: 63b8f3541a9792d705e67d5aca7b6451
|
||||
@@ -2073,7 +2288,7 @@ checksums:
|
||||
workspace/settings/billing/most_popular: 03051978338d93d9abdd999bc06284f9
|
||||
workspace/settings/billing/pending_change_removed: c80cc7f1f83f28db186e897fb18282a3
|
||||
workspace/settings/billing/pending_plan_badge: 1283929a2810dcf6110765f387dc118e
|
||||
workspace/settings/billing/pending_plan_change_description: a50400c802ab04c23019d8219c5e7e1c
|
||||
workspace/settings/billing/pending_plan_change_description: 6923bada769d33cadcad557521362c1f
|
||||
workspace/settings/billing/pending_plan_change_title: 730a8df084494ccf06c0a2f44c28f9fc
|
||||
workspace/settings/billing/pending_plan_cta: 1283929a2810dcf6110765f387dc118e
|
||||
workspace/settings/billing/per_month: 64e96490ee2d7811496cf04adae30aa4
|
||||
@@ -2207,6 +2422,7 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/archive_not_allowed: 3ffe3336572a633406858887de60a470
|
||||
workspace/settings/feedback_record_directories/are_you_sure_you_want_to_archive: d249e6e8bc0345835a13f70856eb1c30
|
||||
workspace/settings/feedback_record_directories/assign_workspaces_description: 6c3f0bbf3bd7744bb313f4cd7886e184
|
||||
workspace/settings/feedback_record_directories/connectors_description: 6efec0b94291db18124e8bfb1ced7e89
|
||||
workspace/settings/feedback_record_directories/create_feedback_directory: c178dd6dbd702398df3ac08a9fa43324
|
||||
workspace/settings/feedback_record_directories/description: 8f56b169cb38d8c7b2697bf3a3ed7a61
|
||||
workspace/settings/feedback_record_directories/directory_archived_successfully: fba5b99ced59d0546c8f2241c092a5dd
|
||||
@@ -2218,25 +2434,23 @@ checksums:
|
||||
workspace/settings/feedback_record_directories/directory_unarchived_successfully: 08d56e260decc62fe664b50ab774b728
|
||||
workspace/settings/feedback_record_directories/directory_updated_successfully: 638cb6c92f535328d809274cf2be4d7d
|
||||
workspace/settings/feedback_record_directories/empty_state: 665593dcb7cfa081a3e719677d0f6b0d
|
||||
workspace/settings/feedback_record_directories/enter_directory_name: a1c950988199bb4c4e014dcf430cce41
|
||||
workspace/settings/feedback_record_directories/error_directory_has_connectors: 792ca3a69d639f4fb602dd72daf5a806
|
||||
workspace/settings/feedback_record_directories/error_directory_name_duplicate: 349d650f562cff96b084787126323ca2
|
||||
workspace/settings/feedback_record_directories/error_directory_name_required: 0f42d7292979006a1069063ab213b8e3
|
||||
workspace/settings/feedback_record_directories/error_directory_workspaces_invalid_org: 477b5c1a466c4194668544ffd42ec9bf
|
||||
workspace/settings/feedback_record_directories/nav_label: cf9a57b3cbac0f04b98e06fb693e986e
|
||||
workspace/settings/feedback_record_directories/no_access: cc3385cd01a11e3949003a2cc6fb5b31
|
||||
workspace/settings/feedback_record_directories/no_connectors: b1becb4fe4e2ba7c5d277db149f092ff
|
||||
workspace/settings/feedback_record_directories/select_workspaces_placeholder: 7d8c8f5910b264525f73bd32107765db
|
||||
workspace/settings/feedback_record_directories/show_archived: c4c1c3bbddc1bb1540c079b589a2d3de
|
||||
workspace/settings/feedback_record_directories/title: e3d425c27f80162f29ce094e31a3fd8f
|
||||
workspace/settings/feedback_record_directories/unarchive: 671fc7e9d7c8cb4d182a25a46551c168
|
||||
workspace/settings/general/ai_data_analysis_disabled_for_organization: 2066fe71ecf8994ba738c79b63a1934b
|
||||
workspace/settings/general/ai_data_analysis_enabled: 45fabb594da6851f73fef50ca40fe525
|
||||
workspace/settings/general/ai_data_analysis_enabled_description: 46d4f0bdf4ebf89e78f79cc961a2de83
|
||||
workspace/settings/general/ai_enabled: 3cb1fce89c525e754448d5bd143eb6b5
|
||||
workspace/settings/general/ai_enabled_description: e8c3e9f362588898a6cea85e18c013a1
|
||||
workspace/settings/general/ai_features_not_enabled_for_organization: e344473bd813fc43f69c51138f74bc8e
|
||||
workspace/settings/general/ai_instance_not_configured: 37a80753eb22b5bfc985d0e1f2145e3f
|
||||
workspace/settings/general/ai_settings_updated_successfully: 2a6f534dc3a246ced46becd8a4a9543d
|
||||
workspace/settings/general/ai_smart_tools_disabled_for_organization: 13df84ae47d35dfa6e86ffa62f29c75d
|
||||
workspace/settings/general/ai_smart_tools_enabled: 1dda984f5262c5f9120ee9a409236758
|
||||
workspace/settings/general/ai_smart_tools_enabled_description: 1ceca6707746d3ab4a530712a06d91da
|
||||
workspace/settings/general/bulk_invite_warning_description: e8737a2fbd5ff353db5580d17b4b5a37
|
||||
@@ -2295,6 +2509,8 @@ checksums:
|
||||
workspace/settings/general/share_invite_link: b40b7ffbcf02d7464be52fb562df5e3a
|
||||
workspace/settings/general/share_this_link_to_let_your_organization_member_join_your_organization: 6eb43d5b1c855572b7ab35f527ba953c
|
||||
workspace/settings/general/test_email_sent_successfully: aa68214f5e0707c9615e01343640ab32
|
||||
workspace/settings/general/unlock_ai_features_description: c15c8c050a4a16d99dc595d9c6419bc4
|
||||
workspace/settings/general/unlock_ai_features_with_a_higher_plan: e0140d3ffd07524fb8f1fec637c4149a
|
||||
workspace/settings/notifications/auto_subscribe_to_new_surveys: 8102c9ce2fbcae53bd8d979c42932fa9
|
||||
workspace/settings/notifications/email_alerts_surveys: 12be5a073d74453a531167debd947bd6
|
||||
workspace/settings/notifications/every_response: 526988e9015f37bc2d32414d7dc05c7c
|
||||
@@ -2383,16 +2599,9 @@ checksums:
|
||||
workspace/settings/teams/you_are_a_member: cf5af638d5371c8fbc337e92519e5150
|
||||
workspace/surveys/all_set_time_to_create_first_survey: 21d3bb74c3b9642b3195d17c17346399
|
||||
workspace/surveys/alphabetical: 5fcfeff9c5fd28714f0a390e0ddaaaee
|
||||
workspace/surveys/copy_survey: de8142b45e7bca61f2dca0069a62b417
|
||||
workspace/surveys/copy_survey_description: b78f714a4a4baae883210b13fb196bd5
|
||||
workspace/surveys/copy_survey_error: 74cab7d84ea8b669e106d4c326cac005
|
||||
workspace/surveys/copy_survey_link_to_clipboard: 77387e3d3de4be07a2a34963f73cd7e8
|
||||
workspace/surveys/copy_survey_no_workspaces: 6f4547d91b2c14dad83c44b01df365eb
|
||||
workspace/surveys/copy_survey_partially_success: a436a5fb7167b95c2308794d35aab070
|
||||
workspace/surveys/copy_survey_success: a829e645fe034b3e712d0b8572a5edc4
|
||||
workspace/surveys/delete_survey_and_responses_warning: 3320c91c1fd27378b7f3d6abc003f2ae
|
||||
workspace/surveys/edit/1_choose_the_default_language_for_this_survey: d22759857c1bb3d6b337e8e9d501dad7
|
||||
workspace/surveys/edit/2_activate_translation_for_specific_languages: 9f23cb81ad301073df45ae36f0d94f9e
|
||||
workspace/surveys/edit/activate_translations: af127c1bed2b47e2012e3a23e489ecb8
|
||||
workspace/surveys/edit/add: 5196f5cd4ba3a6ac8edef91345e17f66
|
||||
workspace/surveys/edit/add_a_delay_or_auto_close_the_survey: b5fa358bf3ff324014060eb0baf6dd2f
|
||||
workspace/surveys/edit/add_a_four_digit_pin: 953cb3673d2135923e3b4474d33ffb2c
|
||||
@@ -2429,6 +2638,18 @@ checksums:
|
||||
workspace/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
||||
workspace/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||
workspace/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||
workspace/surveys/edit/ai_data_analysis_disabled: 2066fe71ecf8994ba738c79b63a1934b
|
||||
workspace/surveys/edit/ai_features_not_enabled: e344473bd813fc43f69c51138f74bc8e
|
||||
workspace/surveys/edit/ai_instance_not_configured: 939ad7c3240fa8de98a325239f1b36bc
|
||||
workspace/surveys/edit/ai_smart_tools_disabled: 13df84ae47d35dfa6e86ffa62f29c75d
|
||||
workspace/surveys/edit/ai_translate: f25943cdeffe155ee524428f4daa5da2
|
||||
workspace/surveys/edit/ai_translating: 098a2293b39f9f258d67f926cf03df37
|
||||
workspace/surveys/edit/ai_translation_all_fields_populated: d78f6a663ea19ce77045970179bd200f
|
||||
workspace/surveys/edit/ai_translation_complete: f443d0801404f728e68000b46ca67598
|
||||
workspace/surveys/edit/ai_translation_failed: fd356a173d0abde7a0fc660394954cc7
|
||||
workspace/surveys/edit/ai_translation_instance_not_configured: 6deeb8aeaff3982d07e1d5a045e06d2d
|
||||
workspace/surveys/edit/ai_translation_not_available: 2f060bf93a558e6d12ec90988fdd162e
|
||||
workspace/surveys/edit/ai_translation_not_enabled: 9066bc85f62ea0e96620c058a4004388
|
||||
workspace/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
|
||||
workspace/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||
workspace/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
@@ -2442,7 +2663,7 @@ checksums:
|
||||
workspace/surveys/edit/audience: a4d9fab4214a641e2d358fbb28f010e0
|
||||
workspace/surveys/edit/auto_close_on_inactivity: 093db516799315ccd4242a3675693012
|
||||
workspace/surveys/edit/auto_progress_rating_and_nps: 76b98e95a5b850850baa0ccc3c7fbf7c
|
||||
workspace/surveys/edit/auto_progress_rating_and_nps_description: cbf676789b9f3f47e36bdf35fa58282b
|
||||
workspace/surveys/edit/auto_progress_rating_and_nps_description: 2a992dd8a5b9532f178f9a21881feb9a
|
||||
workspace/surveys/edit/auto_save_disabled: f7411fb0dcfb8f7b19b85f0be54f2231
|
||||
workspace/surveys/edit/auto_save_disabled_tooltip: 77322e1e866b7d29f7641a88bbd3b681
|
||||
workspace/surveys/edit/auto_save_on: 1524d466830b00c5d727c701db404963
|
||||
@@ -2488,6 +2709,7 @@ checksums:
|
||||
workspace/surveys/edit/caution_text: 3291e962c0e4c4656832837ddc512918
|
||||
workspace/surveys/edit/change_anyway: 6377497d40373f6d0f082670194981ab
|
||||
workspace/surveys/edit/change_background: fa71a993869f7d3ac553c547c12c3e9b
|
||||
workspace/surveys/edit/change_default: 6236a6c8a28489ba7c4cad7426806859
|
||||
workspace/surveys/edit/change_question_type: 2d555ae48df8dbedfc6a4e1ad492f4aa
|
||||
workspace/surveys/edit/change_survey_type: c26322043a476da6d94adb8b4efe1e93
|
||||
workspace/surveys/edit/change_the_background_to_a_color_image_or_animation: f1b9c9eb61497dd91b2550dd50c77836
|
||||
@@ -2499,7 +2721,11 @@ checksums:
|
||||
workspace/surveys/edit/choose_the_first_question_on_your_block: bdece06ca04f89d0c445ba1554dd5b80
|
||||
workspace/surveys/edit/choose_where_to_run_the_survey: ad87bcae97c445f1fd9ac110ea24f117
|
||||
workspace/surveys/edit/city: 1831f32e1babbb29af27fac3053504a2
|
||||
workspace/surveys/edit/clear_close_on_date: 673ed6940f36b02cf871ffacf034e114
|
||||
workspace/surveys/edit/clear_publish_on_date: fe1ffa08c7b95d1dbd6bb8f89d22760f
|
||||
workspace/surveys/edit/close_survey_on_date: 5588ecb41d245dacfb5f7e1b10a97a3e
|
||||
workspace/surveys/edit/close_survey_on_response_limit: 256d0bccdbcbb3d20e39aabc5b376e5e
|
||||
workspace/surveys/edit/code: 343bc5386149b97cece2b093c39034b2
|
||||
workspace/surveys/edit/color: 9d53d1d120e8b8954bcae9a322573748
|
||||
workspace/surveys/edit/column_used_in_logic_error: deffbd3e8f4bd71a5e522682e8ee60dd
|
||||
workspace/surveys/edit/columns: 14896556dc1535d70198854757f704ec
|
||||
@@ -2524,6 +2750,7 @@ checksums:
|
||||
workspace/surveys/edit/customize_survey_logo: 7f7e26026c88a727228f2d7a00d914e2
|
||||
workspace/surveys/edit/darken_or_lighten_background_of_your_choice: 304a64a8050ebf501d195e948cd25b6f
|
||||
workspace/surveys/edit/days_before_showing_this_survey_again: 9ee757e5c3a07844b12ceb406dc65b04
|
||||
workspace/surveys/edit/default_language: 06d01d2598419e36ba97d2d8719f849b
|
||||
workspace/surveys/edit/delete_anyways: cc8683ab625280eefc9776bd381dc2e8
|
||||
workspace/surveys/edit/delete_block: c00617cb0724557e486304276063807a
|
||||
workspace/surveys/edit/delete_choice: fd750208d414b9ad8c980c161a0199e1
|
||||
@@ -2543,7 +2770,6 @@ checksums:
|
||||
workspace/surveys/edit/duplicate_question: 910751de01fdd327165968214717711b
|
||||
workspace/surveys/edit/edit_link: 40ba9e15beac77a46c5baf30be84ac54
|
||||
workspace/surveys/edit/edit_recall: 38a4a7378d02453e35d06f2532eef318
|
||||
workspace/surveys/edit/edit_translations: 2b21bea4b53e88342559272701e9fbf3
|
||||
workspace/surveys/edit/element_not_found: 196777ff6811dd177971ffc8e27a72c1
|
||||
workspace/surveys/edit/enable_participants_to_switch_the_survey_language_at_any_point_during_the_survey: c70466147d49dcbb3686452f35c46428
|
||||
workspace/surveys/edit/enable_recaptcha_to_protect_your_survey_from_spam: 4483a5763718d201ac97caa1e1216e13
|
||||
@@ -2679,11 +2905,13 @@ checksums:
|
||||
workspace/surveys/edit/long_answer: 3a97f8d2e90aba6e679917a0c5670c53
|
||||
workspace/surveys/edit/long_answer_toggle_description: 86bcdfeb74d9825c2f2d5a215e92d111
|
||||
workspace/surveys/edit/lower_label: 45985bca022d4370bd6e013af75d5160
|
||||
workspace/surveys/edit/manage_languages: 9c56d5afee8a73dfc283a452470f3a10
|
||||
workspace/surveys/edit/manage_languages: fe82303bc27b55ccfc076b527b185e39
|
||||
workspace/surveys/edit/manage_translations: 09b01c5c251e6dbc3dc6cd8b33fb6301
|
||||
workspace/surveys/edit/matrix_all_fields: 187240509163b2f52a400a565e57c67f
|
||||
workspace/surveys/edit/matrix_rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
workspace/surveys/edit/max_file_size: 3d35a22048f4d22e24da698fb5fb77d7
|
||||
workspace/surveys/edit/max_file_size_limit_is: 78998639cde3587cecb272ba47e05f9e
|
||||
workspace/surveys/edit/missing_first: a0c8802636ade7bac86a0dacba00b8d4
|
||||
workspace/surveys/edit/move_question_to_block: e8d7ef1e2f727921cb7f5788849492ad
|
||||
workspace/surveys/edit/multiply: 89a0bb629167f97750ae1645a46ced0d
|
||||
workspace/surveys/edit/needed_for_self_hosted_cal_com_instance: d241e72f0332177d32ce6c35070757dc
|
||||
@@ -2691,7 +2919,7 @@ checksums:
|
||||
workspace/surveys/edit/next_button_label: 39f1e82ae1dea5e400e8ed7c98c6ad9c
|
||||
workspace/surveys/edit/no_hidden_fields_yet_add_first_one_below: 9cc6cab3a6a42dbf835215897b5b8516
|
||||
workspace/surveys/edit/no_images_found_for: 7dabcbcc7084f59c6ec0971895dfcd29
|
||||
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 22d7782c8504daf693cab3cf7135d6e3
|
||||
workspace/surveys/edit/no_languages_found_add_first_one_to_get_started: 4e66397232da6a463708220dc020bf42
|
||||
workspace/surveys/edit/no_option_found: a1a3aa7e6c13b6bb8df20a1a104c7c04
|
||||
workspace/surveys/edit/no_recall_items_found: 729e2b02e412cdc79f5ad94b1918620c
|
||||
workspace/surveys/edit/no_variables_yet_add_first_one_below: c8704b9ebc9c26c0e9dd50c099ba88cd
|
||||
@@ -2718,12 +2946,14 @@ checksums:
|
||||
workspace/surveys/edit/please_enter_a_valid_url: 25d43dfb802c31cb59dc88453ea72fc4
|
||||
workspace/surveys/edit/please_set_a_survey_trigger: 0358142df37dd1724f629008a1db453a
|
||||
workspace/surveys/edit/please_specify: e1faa6cd085144f7339c7e74dc6fb366
|
||||
workspace/surveys/edit/present_your_survey_in_multiple_languages: 37f28b0a092d68322fedbc2e0c221ef3
|
||||
workspace/surveys/edit/prevent_double_submission: afc502baa2da81d9c9618da1c3b5a57a
|
||||
workspace/surveys/edit/prevent_double_submission_description: ef7d2aa22d43bdc6ccebb076c6aa9ce5
|
||||
workspace/surveys/edit/progress_saved: d7bfc189571f08bbb4d0240cb9363ffa
|
||||
workspace/surveys/edit/protect_survey_with_pin: 16d1925b6a5770f7423772d6d9a8291a
|
||||
workspace/surveys/edit/protect_survey_with_pin_description: 0e55d19b6f3578b1024e03606172a5d2
|
||||
workspace/surveys/edit/publish: 4aa95ba4793bb293e771bd73b4f87c0f
|
||||
workspace/surveys/edit/publish_survey_on_date: 3167951ef991b911d2e2abb102842452
|
||||
workspace/surveys/edit/question: 0576462ce60d4263d7c482463fcc9547
|
||||
workspace/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
|
||||
workspace/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
|
||||
@@ -2792,6 +3022,7 @@ checksums:
|
||||
workspace/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
workspace/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||
workspace/surveys/edit/scale: 5f55a30a5bdf8f331b56bad9c073473c
|
||||
workspace/surveys/edit/schedule_survey: aa9d0a74a96c325e3202bf1efbc4611a
|
||||
workspace/surveys/edit/search_for_images: 8b1bc3561d126cc49a1ee185c07e7aaf
|
||||
workspace/surveys/edit/seconds_after_trigger_the_survey_will_be_closed_if_no_response: 3584be059fe152e93895ef9885f8e8a7
|
||||
workspace/surveys/edit/seconds_before_showing_the_survey: 4b03756dd5f06df732bf62b2c7968b82
|
||||
@@ -2807,6 +3038,7 @@ checksums:
|
||||
workspace/surveys/edit/seven_points: 4ead50fdfda45e8710767e1b1a84bf42
|
||||
workspace/surveys/edit/show_block_settings: bad99d99c9908874e45f5c350a88cc79
|
||||
workspace/surveys/edit/show_button: 6b364aac9d7ac71f34a438607c9693bc
|
||||
workspace/surveys/edit/show_in_order: 15784a59572eb8a6dba6b918c31a9493
|
||||
workspace/surveys/edit/show_language_switch: b6915a7f26d7079f2d4d844d74440413
|
||||
workspace/surveys/edit/show_multiple_times: 05239c532c9c05ef5d2990ba6ce12f60
|
||||
workspace/surveys/edit/show_only_once: 31858baf60ebcf193c7e35d9084af0af
|
||||
@@ -2838,7 +3070,8 @@ checksums:
|
||||
workspace/surveys/edit/survey_preview: 33644451073149383d3ace08be930739
|
||||
workspace/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
workspace/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
workspace/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
workspace/surveys/edit/survey_will_be_closed_at_midnight_cet: d0a6307e352b3b54fb30d6166201db1f
|
||||
workspace/surveys/edit/survey_will_be_published_at_midnight_cet: d6906ffe76f24b13dab15c50bc24e1a7
|
||||
workspace/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
||||
workspace/surveys/edit/targeted: ca615f1fc3b490d5a2187b27fb4a2073
|
||||
workspace/surveys/edit/ten_points: a1317b82003859f77fb3138c55450d63
|
||||
@@ -2846,9 +3079,11 @@ checksums:
|
||||
workspace/surveys/edit/the_survey_will_be_shown_once_even_if_person_doesnt_respond: e45beba7ae126775f4966776c982a3b4
|
||||
workspace/surveys/edit/then: 5e941fb7dd51a18651fcfb865edd5ba6
|
||||
workspace/surveys/edit/this_action_will_remove_all_the_translations_from_this_survey: 3340c89696f10bdc01b9a1047ff0b987
|
||||
workspace/surveys/edit/this_will_remove_the_language_and_all_its_translations: 6a71ae70abbd61f13f15323d825a47f6
|
||||
workspace/surveys/edit/three_points: d7f299aec752d7d690ef0ab6373327ae
|
||||
workspace/surveys/edit/times: 5ab156c13df6bfd75c0b17ad0a92c78a
|
||||
workspace/surveys/edit/to_keep_the_placement_over_all_surveys_consistent_you_can: 7a078e6a39d4c30b465137d2b6ef3e67
|
||||
workspace/surveys/edit/translated: 5b9d805410310b726f12bacb06da44e3
|
||||
workspace/surveys/edit/trigger_survey_when_one_of_the_actions_is_fired: 8570291668ec9879d204f10e861112db
|
||||
workspace/surveys/edit/try_lollipop_or_mountain: c550a0f07b3ae40a237e30a4314a249c
|
||||
workspace/surveys/edit/type_field_id: 714b845806236bb8a9d6a09933b836e9
|
||||
@@ -2921,6 +3156,7 @@ checksums:
|
||||
workspace/surveys/edit/verify_email_before_submission_description: 434ab3ee6134367513b633a9d4f7d772
|
||||
workspace/surveys/edit/visibility_and_recontact: c27cb4ff3a4262266902a335c3ad5d84
|
||||
workspace/surveys/edit/visibility_and_recontact_description: 2969ab679e1f6111dd96e95cee26e219
|
||||
workspace/surveys/edit/visible: 54ea1310fe55664c24a712eb17070fbd
|
||||
workspace/surveys/edit/wait: 014d18ade977bf08d75b995076596708
|
||||
workspace/surveys/edit/wait_a_few_seconds_after_the_trigger_before_showing_the_survey: 13d5521cf73be5afeba71f5db5847919
|
||||
workspace/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
||||
@@ -3096,6 +3332,8 @@ checksums:
|
||||
workspace/surveys/summary/configure_alerts: 05cc642cd9398034d7e68589d22d97cf
|
||||
workspace/surveys/summary/congrats: 378f06fe96289e527153f8201088ff74
|
||||
workspace/surveys/summary/connect_your_website_or_app_with_formbricks_to_get_started: d24183c86d08b16d58daa8ad887b2837
|
||||
workspace/surveys/summary/csat_satisfied: 4d6121afdc705a70465a230d6d1f6217
|
||||
workspace/surveys/summary/csat_satisfied_tooltip: 3a69a76559a40fbbdff14525d83b459c
|
||||
workspace/surveys/summary/current_count: 6a3e59de8559e88e991e0aeafa9cfeec
|
||||
workspace/surveys/summary/custom_range: 9bc7e02a890644b13b5c0b0bdd96c165
|
||||
workspace/surveys/summary/delete_all_existing_responses_and_displays: e346bcbdb1e0dfbce5925e19fdf0cc78
|
||||
@@ -3103,7 +3341,7 @@ checksums:
|
||||
workspace/surveys/summary/downloading_qr_code: 3c46bf636e617848a4fca9b6c5b51dac
|
||||
workspace/surveys/summary/drop_offs: 605ee950f82110132d6c5780926af109
|
||||
workspace/surveys/summary/drop_offs_tooltip: 2a01683380be45f17636365886cf3452
|
||||
workspace/surveys/summary/failed_to_copy_link: 4e891c757c80e770674e8e74d1c08487
|
||||
workspace/surveys/summary/effort_score: b79157d02a8ead85459c158272951ab5
|
||||
workspace/surveys/summary/filter_added_successfully: e247f65020cd87454bcec0da6f0fd034
|
||||
workspace/surveys/summary/filter_updated_successfully: 01146bc7e6394e271836be2f1b3a257b
|
||||
workspace/surveys/summary/filtered_responses_csv: aad66a98be6a09cac8bef9e4db4a75cf
|
||||
@@ -3153,6 +3391,7 @@ checksums:
|
||||
workspace/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
||||
workspace/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||
workspace/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
workspace/surveys/summary/nps_promoters_tooltip: dea6a683c0c36189e325656d5a7596b8
|
||||
workspace/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
workspace/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
workspace/surveys/summary/promoters: 41fbb8d0439227661253a82fda39f521
|
||||
@@ -3165,7 +3404,6 @@ checksums:
|
||||
workspace/surveys/summary/quotas_completed_tooltip: ec5c4dc67eda27c06764354f695db613
|
||||
workspace/surveys/summary/reset_survey: 8c88ddb81f5f787d183d2e7cb43e7c64
|
||||
workspace/surveys/summary/reset_survey_warning: 6b44be171d7e2716f234387b100b173d
|
||||
workspace/surveys/summary/satisfied: 4d542ba354b85e644acbca5691d2ce45
|
||||
workspace/surveys/summary/selected_responses_csv: 9cef3faccd54d4f24647791e6359db90
|
||||
workspace/surveys/summary/selected_responses_excel: a0ade8b2658e887a4a3f2ad3bdb0c686
|
||||
workspace/surveys/summary/setup_integrations: 70de06d73be671a0cd58a3fd4fa62e53
|
||||
@@ -3175,6 +3413,7 @@ checksums:
|
||||
workspace/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
workspace/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
|
||||
workspace/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
|
||||
workspace/surveys/summary/survey_scheduled_successfully: b5f39d8dc0c203466a0ffb5fa60163e8
|
||||
workspace/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
|
||||
workspace/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
workspace/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
@@ -3188,7 +3427,6 @@ checksums:
|
||||
workspace/surveys/summary/youre_not_plugged_in_yet: f19da3cd474b9a3cf28e956fd811fb00
|
||||
workspace/surveys/survey_deleted_successfully: a6b654cc914b344a4475fd2fd4a98cc5
|
||||
workspace/surveys/survey_duplicated_successfully: 91e244f1e7a33640bb4817166a01ff46
|
||||
workspace/surveys/survey_duplication_error: 35994330aed844ce37d8b4f09df24581
|
||||
workspace/surveys/templates/all_channels: 6be67a82fc7326dc2304b23ab3348b87
|
||||
workspace/surveys/templates/all_industries: c7354412fe34585526ff2232aadace41
|
||||
workspace/surveys/templates/all_roles: 6582ccd0a2349c162a7ae1574cdf76be
|
||||
@@ -3215,6 +3453,118 @@ checksums:
|
||||
workspace/teams/permission: cc2ed7274bd8267f9e0a10b079584d8b
|
||||
workspace/teams/team_name: d1a5f99dbf503ca53f06b3a98b511d02
|
||||
workspace/teams/team_settings_description: 52f91883b9ceb6de83efbf8efd4f11c0
|
||||
workspace/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
workspace/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
workspace/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
workspace/unify/api_ingestion: a14642d27bbb6843f9f4903b6555dfbb
|
||||
workspace/unify/api_ingestion_manage_api_keys: 116786a004fb7b16ead8a5b7a6a2debe
|
||||
workspace/unify/api_ingestion_settings_description: a2597917ca1c724607d1d32178d670b3
|
||||
workspace/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
workspace/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
workspace/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
workspace/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
workspace/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
workspace/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
|
||||
workspace/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
workspace/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
workspace/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
|
||||
workspace/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
|
||||
workspace/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
|
||||
workspace/unify/connectors: 4d6f256254573013a8714c2afe98dcc2
|
||||
workspace/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
|
||||
workspace/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
workspace/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
|
||||
workspace/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
|
||||
workspace/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
|
||||
workspace/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
|
||||
workspace/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
|
||||
workspace/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
|
||||
workspace/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
workspace/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
workspace/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
workspace/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
workspace/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
workspace/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
workspace/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
workspace/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
workspace/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
|
||||
workspace/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
workspace/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
workspace/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
workspace/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
workspace/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
workspace/unify/feedback_record_directory: 89a08a540d1c6eb9f0b1a4b8f56e8aca
|
||||
workspace/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
workspace/unify/feedback_record_mcp: cdddbef2944489820fd7f376a49c2803
|
||||
workspace/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
workspace/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
workspace/unify/feedback_sources: e58ec9be19db8789e7096a756d24f2b2
|
||||
workspace/unify/feedback_sources_directory_access_multiple: 11d613bc1e9825aa6faa3db17ae678eb
|
||||
workspace/unify/feedback_sources_directory_access_single: c9da6b30d410a0ca6302a00a5747dc19
|
||||
workspace/unify/feedback_sources_settings_description: 45f162f2f81cd195c23cb3ec490bb3df
|
||||
workspace/unify/field_label: 6384505ca0e40010c666b712511132a6
|
||||
workspace/unify/field_type: 2581066dc304c853a4a817c20996fa08
|
||||
workspace/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
|
||||
workspace/unify/go_to_feedback_record_directories: 16b66b62f85e7be311778f39315d118a
|
||||
workspace/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
|
||||
workspace/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
|
||||
workspace/unify/import_historical_responses: d7941f65344b6bfba56a40cc53a063b4
|
||||
workspace/unify/import_historical_responses_description: c860f7c6dbe8b74383ecf9cae9c219a0
|
||||
workspace/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
workspace/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
|
||||
workspace/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
|
||||
workspace/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
|
||||
workspace/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
|
||||
workspace/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
|
||||
workspace/unify/manage_directories: 460e00e1cbf1f51de57a2548546e33d7
|
||||
workspace/unify/missing_feedback_source_title: 9ab1b8d54b4da72dd00ce03fe3b698b5
|
||||
workspace/unify/no_feedback_record_directory_available: b8126ef5d6276d9655a9b27ffcaca824
|
||||
workspace/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
workspace/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
workspace/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
workspace/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
workspace/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
workspace/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
|
||||
workspace/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
|
||||
workspace/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
|
||||
workspace/unify/request_feedback_source: 51045caa2c81dee971d23a1841d19a7e
|
||||
workspace/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
workspace/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
workspace/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
|
||||
workspace/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
|
||||
workspace/unify/select_feedback_record_directory: 88afbf2c2a322249908ee5d00ec5f65d
|
||||
workspace/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
|
||||
workspace/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
|
||||
workspace/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
workspace/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
|
||||
workspace/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
|
||||
workspace/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
|
||||
workspace/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
|
||||
workspace/unify/showing_count_loaded: f443aae08223b65fbd5521d6e69534a4
|
||||
workspace/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
workspace/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
workspace/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
workspace/unify/source_connect_feedback_record_mcp_description: a3f56e2a6e403f4021e83f1b1a466d95
|
||||
workspace/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
workspace/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
workspace/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
workspace/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
workspace/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
workspace/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
workspace/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
workspace/unify/status_live_sync: 7e794257419414f57d34845ef38d0939
|
||||
workspace/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
workspace/unify/status_ready: 437c0eea608e15ad5cdab94bde2f4b48
|
||||
workspace/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
|
||||
workspace/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
workspace/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
workspace/unify/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
workspace/unify/upload_csv_data_description: 7fab46222ab05a4424db90a7cc96cdf5
|
||||
workspace/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
workspace/unify/user_identifier: 61073457a5c3901084b557d065f876be
|
||||
workspace/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
workspace/xm-templates/ces: e2ea309b2f7f13257967b966c2fda1e9
|
||||
workspace/xm-templates/ces_description: c8d9794dd17d5ab85a979f1b3e1bc935
|
||||
workspace/xm-templates/csat: fdfc1dc6214cce661dcdc32a71d80337
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
const mockStartJobsRuntime = vi.fn();
|
||||
const mockRemoveRecurringSurveySchedulingJobSchedule = vi.fn();
|
||||
const mockUpsertRecurringSurveySchedulingJobSchedule = vi.fn();
|
||||
const mockDebug = vi.fn();
|
||||
const mockError = vi.fn();
|
||||
const mockWarn = vi.fn();
|
||||
const mockGetJobsQueueingConfig = vi.fn();
|
||||
const mockGetJobsWorkerBootstrapConfig = vi.fn();
|
||||
const mockProcessResponsePipelineJob = vi.fn();
|
||||
const mockProcessSurveySchedulingJob = vi.fn();
|
||||
const TEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
const slowTest = (name: string, fn: () => Promise<void>): void => {
|
||||
test(name, fn, TEST_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
vi.mock("@formbricks/jobs", () => ({
|
||||
removeRecurringSurveySchedulingJobSchedule: mockRemoveRecurringSurveySchedulingJobSchedule,
|
||||
startJobsRuntime: mockStartJobsRuntime,
|
||||
upsertRecurringSurveySchedulingJobSchedule: mockUpsertRecurringSurveySchedulingJobSchedule,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/config", () => ({
|
||||
getJobsQueueingConfig: mockGetJobsQueueingConfig,
|
||||
getJobsWorkerBootstrapConfig: mockGetJobsWorkerBootstrapConfig,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
debug: mockDebug,
|
||||
error: mockError,
|
||||
info: vi.fn(),
|
||||
warn: mockWarn,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/response-pipeline/lib/process-response-pipeline-job", () => ({
|
||||
processResponsePipelineJob: mockProcessResponsePipelineJob,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/survey/scheduling/lib/process-survey-scheduling-job", () => ({
|
||||
processSurveySchedulingJob: mockProcessSurveySchedulingJob,
|
||||
}));
|
||||
|
||||
describe("instrumentation-jobs", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
mockRemoveRecurringSurveySchedulingJobSchedule.mockResolvedValue(true);
|
||||
mockGetJobsQueueingConfig.mockReturnValue({
|
||||
enabled: false,
|
||||
redisUrl: null,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { resetJobsWorkerRegistrationForTests } = await import("./instrumentation-jobs");
|
||||
await resetJobsWorkerRegistrationForTests();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
slowTest("skips worker startup when disabled", async () => {
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: false,
|
||||
runtimeOptions: null,
|
||||
});
|
||||
|
||||
const { registerJobsWorker } = await import("./instrumentation-jobs");
|
||||
const result = await registerJobsWorker();
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
|
||||
expect(mockUpsertRecurringSurveySchedulingJobSchedule).not.toHaveBeenCalled();
|
||||
expect(mockDebug).toHaveBeenCalledWith("BullMQ worker startup skipped");
|
||||
});
|
||||
|
||||
slowTest("starts the worker once and registers handlers", async () => {
|
||||
const mockRuntime = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
const mockExistingOverride = vi.fn();
|
||||
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
runtimeOptions: {
|
||||
concurrency: 4,
|
||||
jobHandlerOverrides: {
|
||||
"test-log.process": mockExistingOverride,
|
||||
},
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
mockStartJobsRuntime.mockResolvedValue(mockRuntime);
|
||||
const { registerJobsWorker } = await import("./instrumentation-jobs");
|
||||
const first = await registerJobsWorker();
|
||||
const second = await registerJobsWorker();
|
||||
|
||||
expect(first).toBe(mockRuntime);
|
||||
expect(second).toBe(mockRuntime);
|
||||
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
|
||||
expect(mockStartJobsRuntime).toHaveBeenCalledWith({
|
||||
concurrency: 4,
|
||||
jobHandlerOverrides: {
|
||||
"response-pipeline.process": expect.any(Function),
|
||||
"survey-scheduling.reconcile": expect.any(Function),
|
||||
"test-log.process": mockExistingOverride,
|
||||
},
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerCount: 2,
|
||||
});
|
||||
const overrides = mockStartJobsRuntime.mock.calls[0]?.[0]?.jobHandlerOverrides;
|
||||
const responsePipelineOverride = overrides?.["response-pipeline.process"];
|
||||
const surveySchedulingOverride = overrides?.["survey-scheduling.reconcile"];
|
||||
|
||||
await responsePipelineOverride?.(
|
||||
{
|
||||
workspaceId: "ws_123",
|
||||
event: "responseCreated",
|
||||
response: { id: "res_123" },
|
||||
surveyId: "survey_123",
|
||||
},
|
||||
{
|
||||
attempt: 1,
|
||||
jobId: "job_123",
|
||||
jobName: "response-pipeline.process",
|
||||
maxAttempts: 3,
|
||||
queueName: "background-jobs",
|
||||
}
|
||||
);
|
||||
await surveySchedulingOverride?.(
|
||||
{
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
attempt: 1,
|
||||
jobId: "job_456",
|
||||
jobName: "survey-scheduling.reconcile",
|
||||
maxAttempts: 3,
|
||||
queueName: "background-jobs",
|
||||
}
|
||||
);
|
||||
|
||||
expect(mockProcessResponsePipelineJob).toHaveBeenCalledWith(
|
||||
{
|
||||
workspaceId: "ws_123",
|
||||
event: "responseCreated",
|
||||
response: { id: "res_123" },
|
||||
surveyId: "survey_123",
|
||||
},
|
||||
{
|
||||
attempt: 1,
|
||||
jobId: "job_123",
|
||||
jobName: "response-pipeline.process",
|
||||
maxAttempts: 3,
|
||||
queueName: "background-jobs",
|
||||
}
|
||||
);
|
||||
expect(mockProcessSurveySchedulingJob).toHaveBeenCalledWith(
|
||||
{
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
attempt: 1,
|
||||
jobId: "job_456",
|
||||
jobName: "survey-scheduling.reconcile",
|
||||
maxAttempts: 3,
|
||||
queueName: "background-jobs",
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
slowTest("reuses the in-flight startup promise", async () => {
|
||||
const mockRuntime = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
runtimeOptions: {
|
||||
concurrency: 2,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
let resolveRuntime: ((value: typeof mockRuntime) => void) | undefined;
|
||||
mockStartJobsRuntime.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
resolveRuntime = resolve;
|
||||
})
|
||||
);
|
||||
const { registerJobsWorker } = await import("./instrumentation-jobs");
|
||||
const firstPromise = registerJobsWorker();
|
||||
const secondPromise = registerJobsWorker();
|
||||
|
||||
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(1);
|
||||
|
||||
resolveRuntime?.(mockRuntime);
|
||||
|
||||
await expect(firstPromise).resolves.toBe(mockRuntime);
|
||||
await expect(secondPromise).resolves.toBe(mockRuntime);
|
||||
});
|
||||
|
||||
slowTest("logs and rethrows startup failures", async () => {
|
||||
const startupError = new Error("startup failed");
|
||||
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
runtimeOptions: {
|
||||
concurrency: 1,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
mockStartJobsRuntime.mockRejectedValue(startupError);
|
||||
|
||||
const { registerJobsWorker } = await import("./instrumentation-jobs");
|
||||
|
||||
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
|
||||
expect(mockError).toHaveBeenCalledWith({ err: startupError }, "BullMQ worker registration failed");
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
{ retryDelayMs: 30_000 },
|
||||
"BullMQ worker registration retry scheduled"
|
||||
);
|
||||
});
|
||||
|
||||
slowTest("retries worker startup after a transient failure", async () => {
|
||||
const startupError = new Error("startup failed");
|
||||
const recoveredRuntime = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
runtimeOptions: {
|
||||
concurrency: 1,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
mockStartJobsRuntime.mockRejectedValueOnce(startupError).mockResolvedValueOnce(recoveredRuntime);
|
||||
const { registerJobsWorker } = await import("./instrumentation-jobs");
|
||||
|
||||
await expect(registerJobsWorker()).rejects.toThrow("startup failed");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
|
||||
await expect(registerJobsWorker()).resolves.toBe(recoveredRuntime);
|
||||
});
|
||||
|
||||
slowTest(
|
||||
"registers recurring schedules once when queueing is enabled without an in-process worker",
|
||||
async () => {
|
||||
mockGetJobsQueueingConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
});
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: false,
|
||||
runtimeOptions: null,
|
||||
});
|
||||
mockUpsertRecurringSurveySchedulingJobSchedule.mockResolvedValue({
|
||||
id: "schedule-job-1",
|
||||
name: "survey-scheduling.reconcile",
|
||||
queueName: "background-jobs",
|
||||
});
|
||||
|
||||
const { registerRecurringJobs } = await import("./instrumentation-jobs");
|
||||
const { SURVEY_SCHEDULING_DAILY_CRON_PATTERN, SURVEY_SCHEDULING_TIME_ZONE } =
|
||||
await import("@/modules/survey/scheduling/lib/constants");
|
||||
|
||||
await registerRecurringJobs();
|
||||
await registerRecurringJobs();
|
||||
|
||||
expect(mockStartJobsRuntime).not.toHaveBeenCalled();
|
||||
expect(mockRemoveRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(mockRemoveRecurringSurveySchedulingJobSchedule).toHaveBeenCalledWith({
|
||||
scheduleId: "daily-survey-scheduling",
|
||||
scope: "global",
|
||||
});
|
||||
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledWith(
|
||||
{
|
||||
scheduleId: "daily-survey-scheduling",
|
||||
scope: "global",
|
||||
},
|
||||
{
|
||||
cronPattern: SURVEY_SCHEDULING_DAILY_CRON_PATTERN,
|
||||
kind: "cron",
|
||||
timeZone: SURVEY_SCHEDULING_TIME_ZONE,
|
||||
},
|
||||
{
|
||||
scope: "global",
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
slowTest("retries recurring schedule registration after a transient failure", async () => {
|
||||
const scheduleError = new Error("schedule failed");
|
||||
|
||||
mockGetJobsQueueingConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
});
|
||||
mockUpsertRecurringSurveySchedulingJobSchedule
|
||||
.mockRejectedValueOnce(scheduleError)
|
||||
.mockResolvedValueOnce({
|
||||
id: "schedule-job-1",
|
||||
name: "survey-scheduling.reconcile",
|
||||
queueName: "background-jobs",
|
||||
});
|
||||
|
||||
const { registerRecurringJobs } = await import("./instrumentation-jobs");
|
||||
|
||||
await expect(registerRecurringJobs()).rejects.toThrow("schedule failed");
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
{ err: scheduleError },
|
||||
"BullMQ recurring job registration failed"
|
||||
);
|
||||
expect(mockWarn).toHaveBeenCalledWith(
|
||||
{ retryDelayMs: 30_000 },
|
||||
"BullMQ recurring job registration retry scheduled"
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
|
||||
expect(mockUpsertRecurringSurveySchedulingJobSchedule).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
slowTest("clears registration state even when reset close fails", async () => {
|
||||
const failingRuntime = {
|
||||
close: vi.fn().mockRejectedValue(new Error("close failed")),
|
||||
};
|
||||
const nextRuntime = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
mockGetJobsWorkerBootstrapConfig.mockReturnValue({
|
||||
enabled: true,
|
||||
runtimeOptions: {
|
||||
concurrency: 1,
|
||||
redisUrl: "redis://localhost:6379",
|
||||
workerCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
mockStartJobsRuntime.mockResolvedValueOnce(failingRuntime).mockResolvedValueOnce(nextRuntime);
|
||||
const { registerJobsWorker, resetJobsWorkerRegistrationForTests } =
|
||||
await import("./instrumentation-jobs");
|
||||
|
||||
await expect(registerJobsWorker()).resolves.toBe(failingRuntime);
|
||||
await expect(resetJobsWorkerRegistrationForTests()).resolves.toBeUndefined();
|
||||
await expect(registerJobsWorker()).resolves.toBe(nextRuntime);
|
||||
|
||||
expect(mockStartJobsRuntime).toHaveBeenCalledTimes(2);
|
||||
expect(mockError).toHaveBeenCalledWith(
|
||||
{ err: expect.any(Error) },
|
||||
"BullMQ worker test reset close failed"
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user