Compare commits

..

14 Commits

Author SHA1 Message Date
Anshuman Pandey
cffeb0513e feat: feedback records table (#7422)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-09 09:11:18 +01:00
Anshuman Pandey
077a9934ad feat: csv connector (#7361)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-03-05 13:57:14 +01:00
Tiago
1ed8d8076e chore: remove unused postgres-init-dev volume mount from dev compose (#7412) 2026-03-04 17:34:51 +01:00
Anshuman Pandey
8b048c3105 fix: polish formbricks connector (#7348)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Harsh Bhat <harsh121102@gmail.com>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
2026-02-26 13:08:26 +01:00
Anshuman Pandey
b2705a4f8f feat: use @formbricks/hub SDK instead of custom hub-client (#7357) 2026-02-26 13:22:52 +04:00
pandeymangg
e867caa373 adds tests for hub-client 2026-02-26 14:34:16 +05:30
Tiago Farto
de79b58648 chore: fix lock file 2026-02-25 17:28:11 +00:00
Tiago Farto
04d528b9b8 chore: fix lock file 2026-02-25 17:22:56 +00:00
Tiago Farto
c815b11015 chore: refactored hub client 2026-02-25 17:19:53 +00:00
Tiago Farto
1e7830d850 chore: refactored hub initialization 2026-02-25 13:38:19 +00:00
Tiago Farto
77cd1e9bd1 chore: additional test coverage 2026-02-25 12:46:05 +00:00
Tiago Farto
e665227437 feat(connector): use @formbricks/hub SDK instead of custom hub-client
- Remove apps/web/lib/connector/hub-client.ts
- Add @formbricks/hub dependency to apps/web
- pipeline-handler: create Hub client from env (HUB_API_KEY, HUB_API_URL), call SDK create in batch with same results shape
- transform: use FormbricksHub.FeedbackRecordCreateParams from SDK directly

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-25 12:20:46 +00:00
Harsh Bhat
75e71e39bc feat: Unify POC hackathon (#7169)
Co-authored-by: Harsh Bhat <harshbhat@Harshs-MacBook-Air.local>
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: TheodorTomas <theodortomas@gmail.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-02-24 17:59:28 +01:00
Tiago
337aedf463 feat: Add Formbricks Hub to Docker Compose and Helm chart (#7284) 2026-02-23 14:35:33 +01:00
574 changed files with 23142 additions and 16034 deletions

View File

@@ -38,6 +38,15 @@ LOG_LEVEL=info
DATABASE_URL='postgresql://postgres:postgres@localhost:5432/formbricks?schema=public'
#################
# HUB (DEV) #
#################
# The dev stack (pnpm db:up / pnpm go) runs Formbricks Hub on port 8080.
# Set explicitly to avoid confusion; override as needed when using docker-compose.dev.yml.
HUB_API_KEY=dev-api-key
HUB_API_URL=http://localhost:8080
HUB_DATABASE_URL=postgresql://postgres:postgres@postgres:5432/postgres?sslmode=disable
################
# MAIL SETUP #
################

View File

@@ -6,9 +6,19 @@ permissions:
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
push:
branches:
- main
paths:
- "apps/web/**/*.ts"
- "apps/web/**/*.tsx"
- "apps/web/locales/**/*.json"
- "scan-translations.ts"
jobs:
validate-translations:
@@ -23,38 +33,30 @@ jobs:
egress-policy: audit
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Check for relevant changes
id: changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
with:
filters: |
translations:
- 'apps/web/**/*.ts'
- 'apps/web/**/*.tsx'
- 'apps/web/locales/**/*.json'
- 'packages/surveys/src/**/*.{ts,tsx}'
- 'packages/surveys/locales/**/*.json'
- 'packages/email/**/*.{ts,tsx}'
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Setup Node.js 22.x
if: steps.changes.outputs.translations == 'true'
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
with:
node-version: 22.x
- name: Install pnpm
if: steps.changes.outputs.translations == 'true'
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Install dependencies
if: steps.changes.outputs.translations == 'true'
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Validate translation keys
if: steps.changes.outputs.translations == 'true'
run: pnpm run scan-translations
run: |
echo ""
echo "🔍 Validating translation keys..."
echo ""
pnpm run scan-translations
- name: Skip (no translation-related changes)
if: steps.changes.outputs.translations != 'true'
run: echo "No translation-related files changed — skipping validation."
- name: Summary
if: success()
run: |
echo ""
echo "✅ Translation validation completed successfully!"
echo ""

2
.husky/post-checkout Normal file
View File

@@ -0,0 +1,2 @@
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
prettier --write ./branch.json

View File

@@ -1 +1,40 @@
pnpm lint-staged
# Load environment variables from .env files
if [ -f .env ]; then
set -a
. .env
set +a
fi
pnpm lint-staged
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
if [ -n "$LINGODOTDEV_API_KEY" ]; then
echo ""
echo "🌍 Running Lingo.dev translation workflow..."
echo ""
# Run translation generation and validation
if pnpm run i18n; then
echo ""
echo "✅ Translation validation passed"
echo ""
# Add updated locale files to git
git add apps/web/locales/*.json
else
echo ""
echo "❌ Translation validation failed!"
echo ""
echo "Please fix the translation issues above before committing:"
echo " • Add missing translation keys to your locale files"
echo " • Remove unused translation keys"
echo ""
echo "Or run 'pnpm i18n' to see the detailed report"
echo ""
exit 1
fi
else
echo ""
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
echo " (This is expected for community contributors)"
echo ""
fi

View File

@@ -10,20 +10,25 @@
"build-storybook": "storybook build",
"clean": "rimraf .turbo node_modules dist storybook-static"
},
"dependencies": {
"@formbricks/survey-ui": "workspace:*"
},
"devDependencies": {
"@chromatic-com/storybook": "^5.0.1",
"@storybook/addon-a11y": "10.2.15",
"@storybook/addon-links": "10.2.15",
"@storybook/addon-onboarding": "10.2.15",
"@storybook/react-vite": "10.2.15",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@tailwindcss/vite": "4.2.1",
"@typescript-eslint/parser": "8.56.1",
"@vitejs/plugin-react": "5.1.4",
"@chromatic-com/storybook": "^5.0.0",
"@storybook/addon-a11y": "10.1.11",
"@storybook/addon-links": "10.1.11",
"@storybook/addon-onboarding": "10.1.11",
"@storybook/react-vite": "10.1.11",
"@typescript-eslint/eslint-plugin": "8.53.0",
"@tailwindcss/vite": "4.1.18",
"@typescript-eslint/parser": "8.53.0",
"@vitejs/plugin-react": "5.1.2",
"esbuild": "0.25.12",
"eslint-plugin-react-refresh": "0.4.26",
"eslint-plugin-storybook": "10.2.14",
"storybook": "10.2.15",
"eslint-plugin-storybook": "10.1.11",
"prop-types": "15.8.1",
"storybook": "10.1.11",
"vite": "7.3.1",
"@storybook/addon-docs": "10.2.15"
"@storybook/addon-docs": "10.1.11"
}
}

View File

@@ -1,6 +0,0 @@
const baseConfig = require("../../.prettierrc.js");
module.exports = {
...baseConfig,
tailwindConfig: "./tailwind.config.js",
};

View File

@@ -101,9 +101,6 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
# Create packages/database directory structure with proper ownership for runtime migrations
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma

View File

@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
) : (
<div className="flex animate-pulse flex-col items-center space-y-4">
<span className="relative flex h-10 w-10">
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
</span>
<p className="pt-4 text-sm font-medium text-slate-600">

View File

@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
channel={channel}
/>
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}`}>

View File

@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
<XMTemplateList project={project} user={user} environmentId={environment.id} />
{projects.length >= 2 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={`/environments/${environment.id}/surveys`}>

View File

@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
return (
<aside
className={cn(
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
)}>
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />

View File

@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
<OnboardingOptionsContainer options={channelOptions} />
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -228,7 +228,7 @@ export const ProjectSettings = ({
</FormProvider>
</div>
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
{logoUrl && (
<Image
src={logoUrl}
@@ -239,16 +239,18 @@ export const ProjectSettings = ({
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || t("common.my_product"), t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
<div className="z-0 h-3/4 w-3/4">
<SurveyInline
appUrl={publicDomain}
isPreviewMode={true}
survey={previewSurvey(projectName || "my Product", t)}
styling={previewStyling}
isBrandingEnabled={false}
languageCode="default"
onFileUpload={async (file) => file.name}
autoFocus={false}
/>
</div>
</div>
<CreateTeamModal
open={createTeamModalOpen}

View File

@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -1,7 +1,7 @@
import { z } from "zod";
export const ZOrganizationTeam = z.object({
id: z.cuid2(),
id: z.string().cuid2(),
name: z.string(),
});

View File

@@ -25,7 +25,7 @@ const ZCreateProjectAction = z.object({
data: ZProjectUpdateInput,
});
export const createProjectAction = authenticatedActionClient.inputSchema(ZCreateProjectAction).action(
export const createProjectAction = authenticatedActionClient.schema(ZCreateProjectAction).action(
withAuditLogging(
"created",
"project",
@@ -97,7 +97,7 @@ const ZGetOrganizationsForSwitcherAction = z.object({
* Called on-demand when user opens the organization switcher.
*/
export const getOrganizationsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetOrganizationsForSwitcherAction)
.schema(ZGetOrganizationsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -122,7 +122,7 @@ const ZGetProjectsForSwitcherAction = z.object({
* Called on-demand when user opens the project switcher.
*/
export const getProjectsForSwitcherAction = authenticatedActionClient
.inputSchema(ZGetProjectsForSwitcherAction)
.schema(ZGetProjectsForSwitcherAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,

View File

@@ -9,9 +9,9 @@ import {
PanelLeftCloseIcon,
PanelLeftOpenIcon,
RocketIcon,
ShapesIcon,
UserCircleIcon,
UserIcon,
WorkflowIcon,
} from "lucide-react";
import Image from "next/image";
import Link from "next/link";
@@ -100,7 +100,7 @@ export const MainNavigation = ({
const mainNavigation = useMemo(
() => [
{
name: t("common.surveys"),
name: t("common.ask"),
href: `/environments/${environment.id}/surveys`,
icon: MessageCircle,
isActive: pathname?.includes("/surveys"),
@@ -108,7 +108,7 @@ export const MainNavigation = ({
},
{
href: `/environments/${environment.id}/contacts`,
name: t("common.contacts"),
name: t("common.distribute"),
icon: UserIcon,
isActive:
pathname?.includes("/contacts") ||
@@ -116,20 +116,19 @@ export const MainNavigation = ({
pathname?.includes("/attributes"),
},
{
name: t("common.workflows"),
href: `/environments/${environment.id}/workflows`,
icon: WorkflowIcon,
isActive: pathname?.includes("/workflows"),
isHidden: !isFormbricksCloud,
name: t("common.unify"),
href: `/environments/${environment.id}/workspace/unify`,
icon: ShapesIcon,
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
},
{
name: t("common.configuration"),
name: t("common.configure"),
href: `/environments/${environment.id}/workspace/general`,
icon: Cog,
isActive: pathname?.includes("/project"),
},
],
[t, environment.id, pathname, isFormbricksCloud]
[t, environment.id, pathname]
);
const dropdownNavigation = [

View File

@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
<currentStatus.icon />
</div>
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
{status === "notImplemented" && (
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
<RotateCcwIcon />

View File

@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort organizations by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setOrganizations(sorted);
} else {
// Handle server errors or validation errors

View File

@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
if (result?.data) {
// Sort projects by name
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
setProjects(sorted);
} else {
// Handle server errors or validation errors
@@ -133,6 +133,11 @@ export const ProjectBreadcrumb = ({
label: t("common.tags"),
href: `/environments/${currentEnvironmentId}/workspace/tags`,
},
{
id: "unify",
label: t("common.unify"),
href: `/environments/${currentEnvironmentId}/workspace/unify`,
},
];
if (!currentProject) {

View File

@@ -11,6 +11,12 @@ const EnvLayout = async (props: {
children: React.ReactNode;
}) => {
const params = await props.params;
const { environmentId } = params;
if (environmentId === "undefined") {
return redirect("/");
}
const { children } = props;
// Check session first (required for userId)

View File

@@ -12,7 +12,7 @@ const ZUpdateNotificationSettingsAction = z.object({
});
export const updateNotificationSettingsAction = authenticatedActionClient
.inputSchema(ZUpdateNotificationSettingsAction)
.schema(ZUpdateNotificationSettingsAction)
.action(
withAuditLogging(
"updated",

View File

@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
const isChecked =
notificationType === "unsubscribedOrganizationIds"
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
const handleSwitchChange = async () => {
setIsLoading(true);
@@ -49,11 +49,8 @@ export const NotificationSwitch = ({
];
}
} else {
updatedNotificationSettings[notificationType] = {
...updatedNotificationSettings[notificationType],
[surveyOrProjectOrOrganizationId]:
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
};
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
}
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
@@ -81,7 +78,7 @@ export const NotificationSwitch = ({
) {
switch (notificationType) {
case "alert":
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
handleSwitchChange();
toast.success(
t(

View File

@@ -63,7 +63,7 @@ async function handleEmailUpdate({
return payload;
}
export const updateUserAction = authenticatedActionClient.inputSchema(ZUserPersonalInfoUpdateInput).action(
export const updateUserAction = authenticatedActionClient.schema(ZUserPersonalInfoUpdateInput).action(
withAuditLogging(
"updated",
"user",

View File

@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { appLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
@@ -198,54 +198,41 @@ export const EditProfileDetailsForm = ({
<FormField
control={form.control}
name="locale"
render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
/>
{isPasswordResetEnabled && (

View File

@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
aria-label="password"
aria-required="true"
required
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
value={field.value}
onChange={(password) => field.onChange(password)}
/>

View File

@@ -17,7 +17,7 @@ const ZUpdateOrganizationNameAction = z.object({
});
export const updateOrganizationNameAction = authenticatedActionClient
.inputSchema(ZUpdateOrganizationNameAction)
.schema(ZUpdateOrganizationNameAction)
.action(
withAuditLogging(
"updated",
@@ -55,36 +55,28 @@ const ZDeleteOrganizationAction = z.object({
organizationId: ZId,
});
export const deleteOrganizationAction = authenticatedActionClient
.inputSchema(ZDeleteOrganizationAction)
.action(
withAuditLogging(
"deleted",
"organization",
async ({
ctx,
parsedInput,
}: {
ctx: AuthenticatedActionClientCtx;
parsedInput: Record<string, any>;
}) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
export const deleteOrganizationAction = authenticatedActionClient.schema(ZDeleteOrganizationAction).action(
withAuditLogging(
"deleted",
"organization",
async ({ ctx, parsedInput }: { ctx: AuthenticatedActionClientCtx; parsedInput: Record<string, any> }) => {
const isMultiOrgEnabled = await getIsMultiOrgEnabled();
if (!isMultiOrgEnabled) throw new OperationNotAllowedError("Organization deletion disabled");
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
ctx.auditLoggingCtx.organizationId = parsedInput.organizationId;
const oldObject = await getOrganization(parsedInput.organizationId);
ctx.auditLoggingCtx.oldObject = oldObject;
return await deleteOrganization(parsedInput.organizationId);
}
)
);

View File

@@ -9,7 +9,6 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import packageJson from "@/package.json";
import { SettingsCard } from "../../components/SettingsCard";
import { DeleteOrganization } from "./components/DeleteOrganization";
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
@@ -82,10 +81,7 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
</SettingsCard>
)}
<div className="space-y-2">
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
</div>
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
</PageContentWrapper>
);
};

View File

@@ -4,7 +4,6 @@ import { revalidatePath } from "next/cache";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
@@ -23,7 +22,7 @@ const ZGetResponsesAction = z.object({
});
export const getResponsesAction = authenticatedActionClient
.inputSchema(ZGetResponsesAction)
.schema(ZGetResponsesAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -57,7 +56,7 @@ const ZGetSurveySummaryAction = z.object({
});
export const getSurveySummaryAction = authenticatedActionClient
.inputSchema(ZGetSurveySummaryAction)
.schema(ZGetSurveySummaryAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -85,7 +84,7 @@ const ZGetResponseCountAction = z.object({
});
export const getResponseCountAction = authenticatedActionClient
.inputSchema(ZGetResponseCountAction)
.schema(ZGetResponseCountAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
});
const ZGetDisplaysWithContactAction = z.object({
surveyId: ZId,
limit: z.int().min(1).max(100),
offset: z.int().nonnegative(),
});
export const getDisplaysWithContactAction = authenticatedActionClient
.inputSchema(ZGetDisplaysWithContactAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
},
],
});
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
});

View File

@@ -3,7 +3,6 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = {
@@ -15,11 +14,10 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) {
return {
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
title: `${responseCount} Responses | ${survey?.name} Results`,
};
}
return {

View File

@@ -22,7 +22,7 @@ const ZSendEmbedSurveyPreviewEmailAction = z.object({
});
export const sendEmbedSurveyPreviewEmailAction = authenticatedActionClient
.inputSchema(ZSendEmbedSurveyPreviewEmailAction)
.schema(ZSendEmbedSurveyPreviewEmailAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
const organizationLogoUrl = await getOrganizationLogoUrl(organizationId);
@@ -69,7 +69,7 @@ const ZResetSurveyAction = z.object({
projectId: ZId,
});
export const resetSurveyAction = authenticatedActionClient.inputSchema(ZResetSurveyAction).action(
export const resetSurveyAction = authenticatedActionClient.schema(ZResetSurveyAction).action(
withAuditLogging(
"updated",
"survey",
@@ -123,7 +123,7 @@ const ZGetEmailHtmlAction = z.object({
});
export const getEmailHtmlAction = authenticatedActionClient
.inputSchema(ZGetEmailHtmlAction)
.schema(ZGetEmailHtmlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -152,7 +152,7 @@ const ZGeneratePersonalLinksAction = z.object({
});
export const generatePersonalLinksAction = authenticatedActionClient
.inputSchema(ZGeneratePersonalLinksAction)
.schema(ZGeneratePersonalLinksAction)
.action(async ({ ctx, parsedInput }) => {
const isContactsEnabled = await getIsContactsEnabled();
if (!isContactsEnabled) {
@@ -231,7 +231,7 @@ const ZUpdateSingleUseLinksAction = z.object({
});
export const updateSingleUseLinksAction = authenticatedActionClient
.inputSchema(ZUpdateSingleUseLinksAction)
.schema(ZUpdateSingleUseLinksAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,

View File

@@ -30,7 +30,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.booked.count })}
{elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -46,7 +47,8 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary.skipped.count })}
{elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />

View File

@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: summaryItem.count })}
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<div className="group-hover:opacity-80">

View File

@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
{`${elementSummary.responseCount} ${t("common.responses")}`}
</div>
)}
{additionalInfo}

View File

@@ -41,7 +41,8 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_responses", { count: elementSummary.responseCount })}
{elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
</div>
</div>
</div>

View File

@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) {
return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
}
return "";
};
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
)}>
<button
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
onClick={() =>
setFilter(
elementSummary.element.id,

View File

@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
{`${elementSummary.selectionCount} ${t("common.selections")}`}
</div>
) : undefined
}
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -123,7 +123,8 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: elementSummary[group]?.count })}
{elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
@@ -157,7 +158,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
}>
<div className="flex h-32 w-full flex-col items-center justify-end">
<div
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
style={{
height: `${Math.max(choice.percentage, 2)}%`,
opacity,

View File

@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{t("common.count_selections", { count: elementSummary.selectionCount })}
{`${elementSummary.selectionCount} ${t("common.selections")}`}
</div>
) : undefined
}
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{t("common.count_selections", { count: result.count })}
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%

View File

@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
)
}>
<div
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
style={{ opacity }}
/>
</ClickableBarSegment>
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{t("common.count_responses", { count: result.count })}
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,7 +215,8 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<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 })}
{elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
</div>

View File

@@ -1,125 +0,0 @@
"use client";
import { AlertCircleIcon, InfoIcon } from "lucide-react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { TUserLocale } from "@formbricks/types/user";
import { timeSince } from "@/lib/time";
import { Button } from "@/modules/ui/components/button";
interface SummaryImpressionsProps {
displays: TDisplayWithContact[];
isLoading: boolean;
hasMore: boolean;
displaysError: string | null;
environmentId: string;
locale: TUserLocale;
onLoadMore: () => void;
onRetry: () => void;
}
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
if (!display.contact) return "";
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
};
export const SummaryImpressions = ({
displays,
isLoading,
hasMore,
displaysError,
environmentId,
locale,
onLoadMore,
onRetry,
}: SummaryImpressionsProps) => {
const { t } = useTranslation();
const renderContent = () => {
if (displaysError) {
return (
<div className="p-8">
<div className="flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2 text-red-600">
<AlertCircleIcon className="h-5 w-5" />
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
</div>
<p className="text-sm text-slate-500">{displaysError}</p>
<Button onClick={onRetry} variant="secondary" size="sm">
{t("common.try_again")}
</Button>
</div>
</div>
);
}
if (displays.length === 0) {
return (
<div className="p-8 text-center text-sm text-slate-500">
{t("environments.surveys.summary.no_identified_impressions")}
</div>
);
}
return (
<>
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
</div>
<div className="max-h-[62vh] overflow-y-auto">
{displays.map((display) => (
<div
key={display.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
<div className="col-span-2 pl-4 md:pl-6">
{display.contact ? (
<Link
className="ph-no-capture break-all text-slate-600 hover:underline"
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
{getDisplayContactIdentifier(display)}
</Link>
) : (
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
)}
</div>
<div className="col-span-2 px-4 text-slate-500 md:px-6">
{timeSince(display.createdAt.toString(), locale)}
</div>
</div>
))}
</div>
{hasMore && (
<div className="flex justify-center border-t border-slate-100 py-4">
<Button onClick={onLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</>
);
};
if (isLoading) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
<div className="flex items-center justify-center">
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
);
}
return (
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<InfoIcon className="h-4 w-4 shrink-0" />
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
</div>
{renderContent()}
</div>
);
};

View File

@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean;
tab: "dropOffs" | "quotas" | "impressions" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
isQuotasAllowed: boolean;
}
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
const { t } = useTranslation();
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
const handleTabChange = (val: "dropOffs" | "quotas") => {
const change = tab === val ? undefined : val;
setTab(change);
};
@@ -65,16 +65,12 @@ export const SummaryMetadata = ({
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}>
<InteractiveCard
key="impressions"
tab="impressions"
<StatCard
label={t("environments.surveys.summary.impressions")}
percentage={null}
value={displayCount === 0 ? <span>-</span> : displayCount}
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
isLoading={isLoading}
onClick={() => handleTabChange("impressions")}
isActive={tab === "impressions"}
/>
<StatCard
label={t("environments.surveys.summary.starts")}

View File

@@ -1,31 +1,21 @@
"use client";
import { useSearchParams } from "next/navigation";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { TDisplayWithContact } from "@formbricks/types/displays";
import { useEffect, useMemo, useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import {
getDisplaysWithContactAction,
getSurveySummaryAction,
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
import { SummaryList } from "./SummaryList";
import { SummaryMetadata } from "./SummaryMetadata";
const DISPLAYS_PER_PAGE = 15;
const defaultSurveySummary: TSurveySummary = {
meta: {
completedPercentage: 0,
@@ -61,76 +51,17 @@ export const SummaryPage = ({
initialSurveySummary,
isQuotasAllowed,
}: SummaryPageProps) => {
const { t } = useTranslation();
const searchParams = useSearchParams();
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
initialSurveySummary || defaultSurveySummary
);
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
const { selectedFilter, dateRange, resetState } = useResponseFilter();
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
const [displaysError, setDisplaysError] = useState<string | null>(null);
const displaysFetchedRef = useRef(false);
const fetchDisplays = useCallback(
async (offset: number) => {
const response = await getDisplaysWithContactAction({
surveyId,
limit: DISPLAYS_PER_PAGE,
offset,
});
if (!response?.data) {
const errorMessage = getFormattedErrorMessage(response);
throw new Error(errorMessage);
}
return response?.data ?? [];
},
[surveyId]
);
const loadInitialDisplays = useCallback(async () => {
setIsDisplaysLoading(true);
setDisplaysError(null);
try {
const data = await fetchDisplays(0);
setDisplays(data);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
toast.error(error);
setDisplays([]);
setHasMoreDisplays(false);
} finally {
setIsDisplaysLoading(false);
}
}, [fetchDisplays, t]);
const handleLoadMoreDisplays = useCallback(async () => {
try {
const data = await fetchDisplays(displays.length);
setDisplays((prev) => [...prev, ...data]);
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
toast.error(errorMessage);
}
}, [fetchDisplays, displays.length, t]);
useEffect(() => {
if (tab === "impressions" && !displaysFetchedRef.current) {
displaysFetchedRef.current = true;
loadInitialDisplays();
}
}, [tab, loadInitialDisplays]);
// Only fetch data when filters change or when there's no initial data
useEffect(() => {
// If we have initial data and no filters are applied, don't fetch
@@ -190,18 +121,6 @@ export const SummaryPage = ({
setTab={setTab}
isQuotasAllowed={isQuotasAllowed}
/>
{tab === "impressions" && (
<SummaryImpressions
displays={displays}
isLoading={isDisplaysLoading}
hasMore={hasMoreDisplays}
displaysError={displaysError}
environmentId={environment.id}
locale={locale}
onLoadMore={handleLoadMoreDisplays}
onRetry={loadInitialDisplays}
/>
)}
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
<div className="flex gap-1.5">

View File

@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
interface InteractiveCardProps {
tab: "dropOffs" | "quotas" | "impressions";
tab: "dropOffs" | "quotas";
label: string;
percentage: number | null;
percentage: number;
value: React.ReactNode;
tooltipText: string;
isLoading: boolean;

View File

@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
},
{
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
},
]}
/>

View File

@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
{projectCustomScripts}
</pre>
</div>
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
rows={8}
placeholder={t("environments.surveys.share.custom_html.placeholder")}
className={cn(
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
)}
{...field}
disabled={isReadOnly}

View File

@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
{t("environments.surveys.summary.use_personal_links")}
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
</button>
<Link
href={`/environments/${environmentId}/settings/notifications`}

View File

@@ -1095,7 +1095,7 @@ export const getResponsesForSummary = reactCache(
[limit, ZOptionalNumber],
[offset, ZOptionalNumber],
[filterCriteria, ZResponseFilterCriteria.optional()],
[cursor, z.cuid2().optional()]
[cursor, z.string().cuid2().optional()]
);
const queryLimit = limit ?? RESPONSES_PER_PAGE;

View File

@@ -28,7 +28,7 @@ const ZGetResponsesDownloadUrlAction = z.object({
});
export const getResponsesDownloadUrlAction = authenticatedActionClient
.inputSchema(ZGetResponsesDownloadUrlAction)
.schema(ZGetResponsesDownloadUrlAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
@@ -58,7 +58,7 @@ const ZGetSurveyFilterDataAction = z.object({
});
export const getSurveyFilterDataAction = authenticatedActionClient
.inputSchema(ZGetSurveyFilterDataAction)
.schema(ZGetSurveyFilterDataAction)
.action(async ({ ctx, parsedInput }) => {
const survey = await getSurvey(parsedInput.surveyId);
@@ -121,7 +121,7 @@ const checkSurveyFollowUpsPermission = async (organizationId: string): Promise<v
}
};
export const updateSurveyAction = authenticatedActionClient.inputSchema(ZSurvey).action(
export const updateSurveyAction = authenticatedActionClient.schema(ZSurvey).action(
withAuditLogging(
"updated",
"survey",

View File

@@ -192,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
value={inputValue}
onValueChange={setInputValue}
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
/>
)}
<Button

View File

@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
<Popover open={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<PopoverTriggerButton isOpen={isOpen}>
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
</PopoverTriggerButton>
</PopoverTrigger>
<PopoverContent
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
</div>
{i !== filterValue.filter.length - 1 && (
<div className="my-4 flex items-center">
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
<p className="mr-4 font-semibold text-slate-800">and</p>
<hr className="w-full text-slate-600" />
</div>
)}

View File

@@ -1,208 +0,0 @@
"use client";
import { CheckCircle2, Sparkles } from "lucide-react";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/modules/ui/components/button";
const FORMBRICKS_HOST = "https://app.formbricks.com";
const SURVEY_ID = "cr9r4b2r73x6hlmn5aa2ha44";
const ENVIRONMENT_ID = "cmk41i8bi92bdad01svi74dec";
interface WorkflowsPageProps {
userEmail: string;
organizationName: string;
billingPlan: string;
}
type Step = "prompt" | "followup" | "thankyou";
export const WorkflowsPage = ({ userEmail, organizationName, billingPlan }: WorkflowsPageProps) => {
const { t } = useTranslation();
const [step, setStep] = useState<Step>("prompt");
const [promptValue, setPromptValue] = useState("");
const [detailsValue, setDetailsValue] = useState("");
const [responseId, setResponseId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleGenerateWorkflow = async () => {
if (promptValue.trim().length < 100 || isSubmitting) return;
setIsSubmitting(true);
try {
const res = await fetch(`${FORMBRICKS_HOST}/api/v2/client/${ENVIRONMENT_ID}/responses`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
surveyId: SURVEY_ID,
finished: false,
data: {
workflow: promptValue.trim(),
useremail: userEmail,
orgname: organizationName,
billingplan: billingPlan,
},
}),
});
if (res.ok) {
const json = await res.json();
setResponseId(json.data?.id ?? null);
}
setStep("followup");
} catch {
setStep("followup");
} finally {
setIsSubmitting(false);
}
};
const handleSubmitFeedback = async () => {
if (isSubmitting) return;
setIsSubmitting(true);
if (responseId) {
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {
details: detailsValue.trim(),
},
}),
});
} catch {
// silently fail
}
}
setIsSubmitting(false);
setStep("thankyou");
};
const handleSkipFeedback = async () => {
if (!responseId) {
setStep("thankyou");
return;
}
try {
await fetch(`${FORMBRICKS_HOST}/api/v1/client/${ENVIRONMENT_ID}/responses/${responseId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
finished: true,
data: {},
}),
});
} catch {
// silently fail
}
setStep("thankyou");
};
if (step === "prompt") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="from-brand-light to-brand-dark mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-gradient-to-br shadow-md">
<Sparkles className="h-6 w-6 text-white" />
</div>
<h1 className="text-4xl font-bold tracking-tight text-slate-800">{t("workflows.heading")}</h1>
<p className="text-lg text-slate-500">{t("workflows.subheading")}</p>
</div>
<div className="relative">
<textarea
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
placeholder={t("workflows.placeholder")}
rows={5}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-xl border border-slate-200 bg-white px-5 py-4 text-base text-slate-800 shadow-sm transition-all placeholder:text-slate-400 focus:outline-none focus:ring-2"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
handleGenerateWorkflow();
}
}}
/>
<div className="mt-3 flex items-center justify-between">
<span
className={`text-xs ${promptValue.trim().length >= 100 ? "text-slate-400" : "text-amber-500"}`}>
{promptValue.trim().length} / 100
</span>
<Button
onClick={handleGenerateWorkflow}
disabled={promptValue.trim().length < 100 || isSubmitting}
loading={isSubmitting}
size="lg">
<Sparkles className="h-4 w-4" />
{t("workflows.generate_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
if (step === "followup") {
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-2xl space-y-8">
<div className="space-y-3 text-center">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-slate-100">
<Sparkles className="text-brand-dark h-6 w-6" />
</div>
<h1 className="text-3xl font-bold tracking-tight text-slate-800">
{t("workflows.coming_soon_title")}
</h1>
<p className="mx-auto max-w-md text-base text-slate-500">
{t("workflows.coming_soon_description")}
</p>
</div>
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
<label className="text-md mb-2 block font-medium text-slate-700">
{t("workflows.follow_up_label")}
</label>
<textarea
value={detailsValue}
onChange={(e) => setDetailsValue(e.target.value)}
placeholder={t("workflows.follow_up_placeholder")}
rows={4}
className="focus:border-brand-dark focus:ring-brand-light/20 w-full resize-none rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-800 transition-all placeholder:text-slate-400 focus:bg-white focus:outline-none focus:ring-2"
/>
<div className="mt-4 flex items-center justify-end gap-3">
<Button variant="ghost" onClick={handleSkipFeedback} className="text-slate-500">
{t("common.skip")}
</Button>
<Button
onClick={handleSubmitFeedback}
disabled={!detailsValue.trim() || isSubmitting}
loading={isSubmitting}>
{t("workflows.submit_button")}
</Button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="flex h-full flex-col items-center px-4 pt-[15vh]">
<div className="w-full max-w-md space-y-6 text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-green-50">
<CheckCircle2 className="h-8 w-8 text-green-500" />
</div>
<h1 className="text-2xl font-bold text-slate-800">{t("workflows.thank_you_title")}</h1>
<p className="text-base text-slate-500">{t("workflows.thank_you_description")}</p>
</div>
</div>
);
};

View File

@@ -1,39 +0,0 @@
import { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getUser } from "@/lib/user/service";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { WorkflowsPage } from "./components/workflows-page";
export const metadata: Metadata = {
title: "Workflows",
};
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
if (!IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, organization, isBilling } = await getEnvironmentAuth(params.environmentId);
if (isBilling) {
return redirect(`/environments/${params.environmentId}/settings/billing`);
}
const user = await getUser(session.user.id);
if (!user) {
return redirect("/auth/login");
}
return (
<WorkflowsPage
userEmail={user.email}
organizationName={organization.name}
billingPlan={organization.billing.plan}
/>
);
};
export default Page;

View File

@@ -21,7 +21,7 @@ const ZCreateOrUpdateIntegrationAction = z.object({
});
export const createOrUpdateIntegrationAction = authenticatedActionClient
.inputSchema(ZCreateOrUpdateIntegrationAction)
.schema(ZCreateOrUpdateIntegrationAction)
.action(
withAuditLogging(
"createdUpdated",
@@ -67,7 +67,7 @@ const ZDeleteIntegrationAction = z.object({
integrationId: ZId,
});
export const deleteIntegrationAction = authenticatedActionClient.inputSchema(ZDeleteIntegrationAction).action(
export const deleteIntegrationAction = authenticatedActionClient.schema(ZDeleteIntegrationAction).action(
withAuditLogging(
"deleted",
"integration",

View File

@@ -1,49 +1,12 @@
"use server";
import { z } from "zod";
import { ZId } from "@formbricks/types/common";
import {
TIntegrationGoogleSheets,
ZIntegrationGoogleSheets,
} from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
import { getIntegrationByType } from "@/lib/integration/service";
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
const ZValidateGoogleSheetsConnectionAction = z.object({
environmentId: ZId,
});
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
.inputSchema(ZValidateGoogleSheetsConnectionAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
access: [
{
type: "organization",
roles: ["owner", "manager"],
},
{
type: "projectTeam",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
minPermission: "readWrite",
},
],
});
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
if (!integration) {
return { data: false };
}
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
return { data: true };
});
const ZGetSpreadsheetNameByIdAction = z.object({
googleSheetIntegration: ZIntegrationGoogleSheets,
environmentId: z.string(),
@@ -51,7 +14,7 @@ const ZGetSpreadsheetNameByIdAction = z.object({
});
export const getSpreadsheetNameByIdAction = authenticatedActionClient
.inputSchema(ZGetSpreadsheetNameByIdAction)
.schema(ZGetSpreadsheetNameByIdAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,

View File

@@ -20,10 +20,6 @@ import {
isValidGoogleSheetsUrl,
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
import {
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
} from "@/lib/googleSheet/constants";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { recallToHeadline } from "@/lib/utils/recall";
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
@@ -122,17 +118,6 @@ export const AddIntegrationModal = ({
resetForm();
}, [selectedIntegration, surveys]);
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
const errorMessage = getFormattedErrorMessage(response);
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
} else {
toast.error(errorMessage);
}
};
const linkSheet = async () => {
try {
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
@@ -144,7 +129,6 @@ export const AddIntegrationModal = ({
if (selectedElements.length === 0) {
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
}
setIsLinkingSheet(true);
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
googleSheetIntegration,
@@ -153,11 +137,13 @@ export const AddIntegrationModal = ({
});
if (!spreadsheetNameResponse?.data) {
showErrorMessageToast(spreadsheetNameResponse);
return;
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
throw new Error(errorMessage);
}
const spreadsheetName = spreadsheetNameResponse.data;
setIsLinkingSheet(true);
integrationData.spreadsheetId = spreadsheetId;
integrationData.spreadsheetName = spreadsheetName;
integrationData.surveyId = selectedSurvey.id;

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useState } from "react";
import { TEnvironment } from "@formbricks/types/environment";
import {
TIntegrationGoogleSheets,
@@ -8,11 +8,9 @@ import {
} from "@formbricks/types/integration/google-sheet";
import { TSurvey } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
import googleSheetLogo from "@/images/googleSheetsLogo.png";
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
import { AddIntegrationModal } from "./AddIntegrationModal";
@@ -37,23 +35,10 @@ export const GoogleSheetWrapper = ({
googleSheetIntegration ? googleSheetIntegration.config?.key : false
);
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
const [selectedIntegration, setSelectedIntegration] = useState<
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
>(null);
const validateConnection = useCallback(async () => {
if (!isConnected || !googleSheetIntegration) return;
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
setShowReconnectButton(true);
}
}, [environment.id, isConnected, googleSheetIntegration]);
useEffect(() => {
validateConnection();
}, [validateConnection]);
const handleGoogleAuthorization = async () => {
authorize(environment.id, webAppUrl).then((url: string) => {
if (url) {
@@ -79,8 +64,6 @@ export const GoogleSheetWrapper = ({
setOpenAddIntegrationModal={setIsModalOpen}
setIsConnected={setIsConnected}
setSelectedIntegration={setSelectedIntegration}
showReconnectButton={showReconnectButton}
handleGoogleAuthorization={handleGoogleAuthorization}
locale={locale}
/>
</>

View File

@@ -1,6 +1,6 @@
"use client";
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
import { Trash2Icon } from "lucide-react";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -12,19 +12,15 @@ import { TUserLocale } from "@formbricks/types/user";
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
import { timeSince } from "@/lib/time";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
interface ManageIntegrationProps {
googleSheetIntegration: TIntegrationGoogleSheets;
setOpenAddIntegrationModal: (v: boolean) => void;
setIsConnected: (v: boolean) => void;
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
showReconnectButton: boolean;
handleGoogleAuthorization: () => void;
locale: TUserLocale;
}
@@ -33,8 +29,6 @@ export const ManageIntegration = ({
setOpenAddIntegrationModal,
setIsConnected,
setSelectedIntegration,
showReconnectButton,
handleGoogleAuthorization,
locale,
}: ManageIntegrationProps) => {
const { t } = useTranslation();
@@ -74,17 +68,7 @@ export const ManageIntegration = ({
return (
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
{showReconnectButton && (
<Alert variant="warning" size="small" className="mb-4 w-full">
<AlertDescription>
{t("environments.integrations.google_sheets.reconnect_button_description")}
</AlertDescription>
<AlertButton onClick={handleGoogleAuthorization}>
{t("environments.integrations.google_sheets.reconnect_button")}
</AlertButton>
</Alert>
)}
<div className="flex w-full justify-end space-x-2">
<div className="flex w-full justify-end">
<div className="mr-6 flex items-center">
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
<span className="text-slate-500">
@@ -93,19 +77,6 @@ export const ManageIntegration = ({
})}
</span>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" onClick={handleGoogleAuthorization}>
<RefreshCcwIcon className="mr-2 h-4 w-4" />
{t("environments.integrations.google_sheets.reconnect_button")}
</Button>
</TooltipTrigger>
<TooltipContent>
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
onClick={() => {
setSelectedIntegration(null);

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
{t("environments.integrations.google_sheets.link_new_sheet")}
</Button>
</div>
@@ -51,7 +51,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>

View File

@@ -10,7 +10,7 @@ const Loading = () => {
<div className="mt-6 p-6">
<GoBackButton />
<div className="mb-6 text-right">
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
{t("environments.integrations.notion.link_database")}
</Button>
</div>
@@ -48,7 +48,7 @@ const Loading = () => {
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
</div>
</div>
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
</div>
<div className="text-center"></div>

View File

@@ -12,7 +12,7 @@ const ZGetSlackChannelsAction = z.object({
});
export const getSlackChannelsAction = authenticatedActionClient
.inputSchema(ZGetSlackChannelsAction)
.schema(ZGetSlackChannelsAction)
.action(async ({ ctx, parsedInput }) => {
await checkAuthorizationUpdated({
userId: ctx.user.id,

View File

@@ -0,0 +1,32 @@
"use client";
import { useTranslation } from "react-i18next";
import { SecondaryNavigation } from "@/modules/ui/components/secondary-navigation";
interface UnifyConfigNavigationProps {
environmentId: string;
activeId?: string;
loading?: boolean;
}
export const UnifyConfigNavigation = ({
environmentId,
activeId: activeIdProp,
loading,
}: UnifyConfigNavigationProps) => {
const { t } = useTranslation();
const baseHref = `/environments/${environmentId}/workspace/unify`;
const activeId = activeIdProp ?? "sources";
const navigation = [
{ id: "sources", label: t("environments.unify.sources"), href: `${baseHref}/sources` },
{
id: "feedback-records",
label: t("environments.unify.feedback_records"),
href: `${baseHref}/feedback-records`,
},
];
return <SecondaryNavigation navigation={navigation} activeId={activeId} loading={loading} />;
};

View File

@@ -0,0 +1,36 @@
"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 {
environmentId: string;
initialRecords: FeedbackRecordData[];
initialTotal: number;
}
export function FeedbackRecordsPageClient({
environmentId,
initialRecords,
initialTotal,
}: FeedbackRecordsPageClientProps) {
const { t } = useTranslation();
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.unify.unify_feedback")}>
<UnifyConfigNavigation environmentId={environmentId} activeId="feedback-records" />
</PageHeader>
<FeedbackRecordsTable
environmentId={environmentId}
initialRecords={initialRecords}
initialTotal={initialTotal}
/>
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import { TFunction } from "i18next";
import {
CalendarIcon,
HashIcon,
MessageSquareTextIcon,
RefreshCwIcon,
ToggleLeftIcon,
TypeIcon,
} from "lucide-react";
import { useCallback, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { listFeedbackRecordsAction } from "@/lib/connector/actions";
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 { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
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 new Date(record.value_date).toLocaleDateString(locale);
return "—";
};
function formatDate(isoString: string, locale: string): string {
return new Date(isoString).toLocaleDateString(locale, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function truncate(str: string, maxLen: number): string {
if (str.length <= maxLen) return str;
return str.slice(0, maxLen) + "…";
}
interface FeedbackRecordsTableProps {
environmentId: string;
initialRecords: FeedbackRecordData[];
initialTotal: number;
}
export const FeedbackRecordsTable = ({
environmentId,
initialRecords,
initialTotal,
}: FeedbackRecordsTableProps) => {
const { t, i18n } = useTranslation();
const [records, setRecords] = useState<FeedbackRecordData[]>(initialRecords);
const [total, setTotal] = useState(initialTotal);
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchRecords = useCallback(
async (offset: number, append: boolean) => {
const setLoading = offset === 0 ? setIsRefreshing : setIsLoadingMore;
setLoading(true);
setError(null);
const result = await listFeedbackRecordsAction({
environmentId,
limit: RECORDS_PER_PAGE,
offset,
});
if (!result?.data) {
setError(getFormattedErrorMessage(result) ?? t("environments.unify.failed_to_load_feedback_records"));
setLoading(false);
return;
}
const response = result.data;
setRecords((prev) => (append ? [...prev, ...response.data] : response.data));
setTotal(response.total);
setLoading(false);
},
[environmentId, t]
);
const handleLoadMore = () => {
fetchRecords(records.length, true);
};
const handleRefresh = async () => {
if (isRefreshing) return;
setIsRefreshing(true);
setError(null);
const toastId = toast.loading(t("environments.unify.refreshing_feedback_records"));
const result = await listFeedbackRecordsAction({
environmentId,
limit: RECORDS_PER_PAGE,
offset: 0,
});
if (!result?.data) {
toast.error(
getFormattedErrorMessage(result) ?? t("environments.unify.failed_to_load_feedback_records"),
{ id: toastId }
);
setIsRefreshing(false);
return;
}
setRecords(result.data.data);
setTotal(result.data.total);
setIsRefreshing(false);
toast.success(t("environments.unify.feedback_records_refreshed"), { id: toastId });
};
const hasMore = records.length < total;
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;
return (
<div className="space-y-3">
{!isEmpty && (
<div className="flex items-center justify-between">
<p className="text-sm text-slate-500">
{t("environments.unify.showing_count", { count: records.length, total })}
</p>
<Button
variant="secondary"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
aria-label={t("environments.unify.refresh_feedback_records")}>
<RefreshCwIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</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("environments.unify.collected_at")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("environments.unify.source_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("environments.unify.source_name")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("environments.unify.field_label")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("environments.unify.field_type")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("environments.unify.value")}</th>
<th className="whitespace-nowrap px-4 py-3">{t("environments.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("environments.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} locale={i18n.language} t={t} />
))}
</tbody>
)}
</table>
</div>
</div>
{hasMore && (
<div className="flex justify-center">
<Button variant="secondary" size="sm" onClick={handleLoadMore} loading={isLoadingMore}>
{t("environments.unify.load_more")}
</Button>
</div>
)}
</div>
);
};
const FeedbackRecordRow = ({
record,
locale,
t,
}: {
record: FeedbackRecordData;
locale: string;
t: TFunction;
}) => {
const value = formatValue(record, t, locale);
const isLongValue = value.length > 60;
return (
<tr className="text-sm text-slate-700 transition-colors hover:bg-slate-50">
<td className="whitespace-nowrap px-4 py-3 text-slate-500">
{formatDate(record.collected_at, locale)}
</td>
<td className="whitespace-nowrap px-4 py-3">
<Badge text={record.source_type} type="gray" size="tiny" />
</td>
<td className="max-w-[150px] truncate px-4 py-3" title={record.source_name ?? undefined}>
{record.source_name ?? "—"}
</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>
);
};

View File

@@ -0,0 +1,46 @@
import { notFound } from "next/navigation";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { listFeedbackRecords } from "@/modules/hub/service";
import { FeedbackRecordsPageClient } from "./feedback-records-page-client";
const INITIAL_PAGE_SIZE = 50;
export default async function UnifyFeedbackRecordsPage(props: {
params: Promise<{ environmentId: string }>;
}) {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getEnvironmentAuth(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const result = await listFeedbackRecords({
tenant_id: params.environmentId,
limit: INITIAL_PAGE_SIZE,
offset: 0,
});
if (result.error) {
throw new Error(t("environments.unify.failed_to_load_feedback_records"));
}
const initialData = result.data ?? { data: [], total: 0, limit: INITIAL_PAGE_SIZE, offset: 0 };
return (
<FeedbackRecordsPageClient
environmentId={params.environmentId}
initialRecords={initialData.data}
initialTotal={initialData.total}
/>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default async function UnifyPage(props: { params: Promise<{ environmentId: string }> }) {
const params = await props.params;
redirect(`/environments/${params.environmentId}/workspace/unify/sources`);
}

View File

@@ -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 { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { transformToUnifySurvey } from "./lib";
import { TUnifySurvey } from "./types";
const ZGetSurveysForUnifyAction = z.object({
environmentId: ZId,
});
export const getSurveysForUnifyAction = authenticatedActionClient
.schema(ZGetSurveysForUnifyAction)
.action(async ({ ctx, parsedInput }): Promise<TUnifySurvey[]> => {
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{
type: "organization",
roles: ["owner", "manager", "member"],
},
{
type: "projectTeam",
minPermission: "read",
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
},
],
});
const surveys = await getSurveys(parsedInput.environmentId);
return surveys.map((survey) => transformToUnifySurvey(survey));
});

View File

@@ -0,0 +1,160 @@
"use client";
import {
CopyIcon,
FileSpreadsheetIcon,
MoreVertical,
PauseIcon,
PlayIcon,
SquarePenIcon,
TrashIcon,
} from "lucide-react";
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 { t } = useTranslation();
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDropDownOpen, setIsDropDownOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isActive = connector.status === "active";
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("environments.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("environments.unify.import_csv_data")}
</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("environments.unify.source")}
open={isDeleteDialogOpen}
setOpen={setIsDeleteDialogOpen}
onDelete={handleDelete}
isDeleting={isDeleting}
/>
</div>
);
}

View File

@@ -0,0 +1,56 @@
"use client";
import { useTranslation } from "react-i18next";
import { TConnectorType } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
import { getConnectorOptions } from "../utils";
interface ConnectorTypeSelectorProps {
selectedType: TConnectorType | null;
onSelectType: (type: TConnectorType) => void;
}
export function ConnectorTypeSelector({ selectedType, onSelectType }: ConnectorTypeSelectorProps) {
const { t } = useTranslation();
const connectorOptions = getConnectorOptions(t);
return (
<div className="space-y-3">
<p className="text-sm text-slate-600">{t("environments.unify.select_source_type_prompt")}</p>
<div className="space-y-2">
{connectorOptions.map((option) => (
<button
key={option.id}
type="button"
disabled={option.disabled}
onClick={() => onSelectType(option.id as TConnectorType)}
className={`flex w-full items-center justify-between rounded-lg border p-4 text-left transition-colors ${
selectedType === option.id
? "border-brand-dark bg-slate-50"
: option.disabled
? "cursor-not-allowed border-slate-200 bg-slate-50 opacity-60"
: "border-slate-200 hover:border-slate-300 hover:bg-slate-50"
}`}>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-slate-900">{option.name}</span>
{option.badge && <Badge text={option.badge.text} type={option.badge.type} size="tiny" />}
</div>
<p className="mt-1 text-sm text-slate-500">{option.description}</p>
</div>
<div
className={`ml-4 h-5 w-5 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-2 w-2 rounded-full bg-white" />
</div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,205 @@
"use client";
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 {
createConnectorWithMappingsAction,
deleteConnectorAction,
duplicateConnectorAction,
updateConnectorWithMappingsAction,
} from "@/lib/connector/actions";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { UnifyConfigNavigation } from "../../components/UnifyConfigNavigation";
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 {
environmentId: string;
initialConnectors: TConnectorWithMappings[];
initialSurveys: TUnifySurvey[];
}
export function ConnectorsSection({
environmentId,
initialConnectors,
initialSurveys,
}: 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 handleCreateConnector = async (data: {
name: string;
type: TConnectorType;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}): Promise<string | undefined> => {
const result = await createConnectorWithMappingsAction({
environmentId,
connectorInput: {
name: data.name,
type: data.type,
},
formbricksMappings:
data.type === "formbricks" && data.surveyMappings?.length ? data.surveyMappings : undefined,
fieldMappings:
data.type !== "formbricks" && 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("environments.unify.connector_created_successfully"));
router.refresh();
return result.data.id;
};
const handleUpdateConnector = async (data: {
connectorId: string;
environmentId: string;
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => {
const result = await updateConnectorWithMappingsAction({
connectorId: data.connectorId,
environmentId,
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("environments.unify.connector_updated_successfully"));
router.refresh();
};
const handleDeleteConnector = async (connectorId: string): Promise<void> => {
const result = await deleteConnectorAction({ connectorId, environmentId });
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_deleted_successfully"));
router.refresh();
};
const handleDuplicateConnector = async (connector: TConnectorWithMappings): Promise<void> => {
const result = await duplicateConnectorAction({
connectorId: connector.id,
environmentId,
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.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,
environmentId,
connectorInput: { status: newStatus },
});
if (!result?.data) {
toast.error(getFormattedErrorMessage(result));
return;
}
toast.success(t("environments.unify.connector_status_updated_successfully"));
router.refresh();
};
return (
<PageContentWrapper>
<PageHeader
pageTitle={t("environments.unify.unify_feedback")}
cta={
<CreateConnectorModal
open={isCreateModalOpen}
onOpenChange={setIsCreateModalOpen}
onCreateConnector={handleCreateConnector}
surveys={initialSurveys}
environmentId={environmentId}
/>
}>
<UnifyConfigNavigation environmentId={environmentId} activeId="sources" />
</PageHeader>
<div className="space-y-6">
<ConnectorsTable
connectors={initialConnectors}
onConnectorClick={setEditingConnector}
onCsvImport={setCsvImportConnector}
onDuplicate={handleDuplicateConnector}
onToggleStatus={handleToggleStatus}
onDelete={handleDeleteConnector}
isLoading={false}
/>
</div>
<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}
environmentId={csvImportConnector.environmentId}
onOpenEditConnector={() => {
setEditingConnector(csvImportConnector);
}}
/>
)}
</PageContentWrapper>
);
}

View File

@@ -0,0 +1,136 @@
"use client";
import { FileSpreadsheetIcon, FormIcon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorStatus, TConnectorType, TConnectorWithMappings } from "@formbricks/types/connector";
import { Badge } from "@/modules/ui/components/badge";
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>;
}
function getConnectorIcon(type: TConnectorType) {
switch (type) {
case "formbricks":
return <FormIcon className="h-4 w-4 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-4 w-4 text-slate-500" />;
default:
return <FormIcon className="h-4 w-4 text-slate-500" />;
}
}
const STATUS_BADGE_TYPE: Record<TConnectorStatus, "success" | "warning" | "error"> = {
active: "success",
paused: "warning",
error: "error",
};
export function ConnectorsTableDataRow({
connector,
onEdit,
onCsvImport,
onDuplicate,
onToggleStatus,
onDelete,
}: ConnectorsTableDataRowProps) {
const { t, i18n } = useTranslation();
const getStatusLabel = (s: TConnectorStatus) => {
switch (s) {
case "active":
return t("environments.unify.status_active");
case "paused":
return t("environments.unify.status_paused");
case "error":
return t("environments.unify.status_error");
}
};
const getConnectorTypeLabel = (connectorType: TConnectorType) => {
switch (connectorType) {
case "formbricks":
return t("environments.unify.formbricks_surveys");
case "csv":
return t("environments.unify.csv_import");
default:
return connectorType;
}
};
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={onEdit}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
onEdit();
}
}}>
<div className="col-span-1 flex items-center gap-2 pl-4" title={getConnectorTypeLabel(connector.type)}>
{getConnectorIcon(connector.type)}
</div>
<div className="col-span-3 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)}
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.createdAt, i18n.language)}
</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>
);
}

View File

@@ -0,0 +1,47 @@
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableDataRow } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/components/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("environments.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>
);
};

View File

@@ -0,0 +1,56 @@
"use client";
import { Loader2Icon } from "lucide-react";
import { useTranslation } from "react-i18next";
import { TConnectorWithMappings } from "@formbricks/types/connector";
import { ConnectorsTableRowsContainer } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/components/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,
}: 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-3">{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("common.created")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("environments.unify.updated_at")}</div>
<div className="col-span-2 hidden text-center sm:block">{t("environments.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>
);
}

View File

@@ -0,0 +1,570 @@
"use client";
import { Loader2Icon, PlusIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
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 { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
FEEDBACK_RECORD_FIELDS,
TCreateConnectorStep,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
import { TEnumValidationError, parseCSVColumnsToFields, validateEnumMappings } from "../utils";
import { ConnectorTypeSelector } from "./connector-type-selector";
import { CsvConnectorUI } from "./csv-connector-ui";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
interface CreateConnectorModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreateConnector: (data: {
name: string;
type: TConnectorType;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<string | undefined>;
surveys: TUnifySurvey[];
environmentId: string;
}
const getDialogTitle = (
step: TCreateConnectorStep,
type: TConnectorType | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("environments.unify.add_feedback_source");
if (type === "formbricks") return t("environments.unify.select_survey_and_questions");
if (type === "csv") return t("environments.unify.import_csv_data");
return t("environments.unify.configure_mapping");
};
const getDialogDescription = (
step: TCreateConnectorStep,
type: TConnectorType | null,
t: (key: string) => string
): string => {
if (step === "selectType") return t("environments.unify.select_source_type_description");
if (type === "formbricks") return t("environments.unify.select_survey_questions_description");
if (type === "csv") return t("environments.unify.upload_csv_data_description");
return t("environments.unify.configure_mapping");
};
const getNextStepButtonLabel = (type: TConnectorType | null, t: (key: string) => string): string => {
if (type === "formbricks") return t("environments.unify.select_questions");
if (type === "csv") return t("environments.unify.configure_import");
return t("environments.unify.create_mapping");
};
const getCreateDisabled = (
type: TConnectorType | null,
isFormbricksValid: boolean,
isCsvValid: boolean,
allRequiredMapped: boolean
): boolean => {
if (type === "formbricks") return !isFormbricksValid;
if (type === "csv") return !isCsvValid || !allRequiredMapped;
return !allRequiredMapped;
};
interface AggregateImportSectionProps {
surveyEntries: {
surveyId: string;
surveyName: string;
responseCount: number;
elementCount: number;
importHistorical: boolean;
}[];
onImportHistoricalChange: (surveyId: string, checked: boolean) => void;
t: (key: string, options?: Record<string, unknown>) => string;
}
const AggregateImportSection = ({
surveyEntries,
onImportHistoricalChange,
t,
}: AggregateImportSectionProps) => {
const totalRecords = surveyEntries.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
const checkedCount = surveyEntries.filter((e) => e.importHistorical).length;
const checkedTotal = surveyEntries
.filter((e) => e.importHistorical)
.reduce((sum, e) => sum + e.responseCount * e.elementCount, 0);
return (
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
<div className="space-y-2">
{surveyEntries.map((entry) => (
<label key={entry.surveyId} className="flex cursor-pointer items-center gap-2">
<input
type="checkbox"
checked={entry.importHistorical}
onChange={(e) => onImportHistoricalChange(entry.surveyId, e.target.checked)}
className="h-4 w-4 rounded border-amber-300 text-amber-600 focus:ring-amber-500"
/>
<span className="text-xs text-amber-800">
{t("environments.unify.survey_import_line", {
surveyName: entry.surveyName,
responseCount: entry.responseCount,
questionCount: entry.elementCount,
total: entry.responseCount * entry.elementCount,
})}
</span>
</label>
))}
</div>
{surveyEntries.length > 1 && (
<p className="mt-3 border-t border-amber-200 pt-2 text-xs font-medium text-amber-900">
{t("environments.unify.total_feedback_records", {
checked: checkedTotal,
total: totalRecords,
surveyCount: checkedCount,
})}
</p>
)}
</div>
);
};
export const CreateConnectorModal = ({
open,
onOpenChange,
onCreateConnector,
surveys,
environmentId,
}: CreateConnectorModalProps) => {
const { t } = useTranslation();
const defaultConnectorName: Record<TConnectorType, string> = {
formbricks: t("environments.unify.default_connector_name_formbricks"),
csv: t("environments.unify.default_connector_name_csv"),
};
const [currentStep, setCurrentStep] = useState<TCreateConnectorStep>("selectType");
const [selectedType, setSelectedType] = useState<TConnectorType | null>(null);
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const [csvParsedData, setCsvParsedData] = useState<Record<string, string>[]>([]);
const [enumValidationErrors, setEnumValidationErrors] = useState<TEnumValidationError[]>([]);
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const [responseCountBySurvey, setResponseCountBySurvey] = useState<Record<string, number | null>>({});
const [importHistoricalBySurvey, setImportHistoricalBySurvey] = useState<Record<string, boolean>>({});
const [isImporting, setIsImporting] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const fetchResponseCount = useCallback(
async (surveyId: string) => {
if (responseCountBySurvey[surveyId] !== undefined) return;
try {
const result = await getResponseCountAction({ surveyId, environmentId });
if (result?.data !== undefined) {
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: result.data ?? null }));
}
} catch {
setResponseCountBySurvey((prev) => ({ ...prev, [surveyId]: null }));
}
},
[environmentId, responseCountBySurvey]
);
useEffect(() => {
if (selectedSurveyId && selectedType === "formbricks") {
fetchResponseCount(selectedSurveyId);
}
}, [selectedSurveyId, selectedType, fetchResponseCount]);
const resetForm = () => {
setCurrentStep("selectType");
setSelectedType(null);
setConnectorName("");
setMappings([]);
setSourceFields([]);
setCsvParsedData([]);
setEnumValidationErrors([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
setResponseCountBySurvey({});
setImportHistoricalBySurvey({});
setIsImporting(false);
setIsCreating(false);
};
const handleOpenChange = (newOpen: boolean) => {
if (isImporting) return;
if (!newOpen) resetForm();
onOpenChange(newOpen);
};
const handleNextStep = () => {
if (currentStep !== "selectType" || !selectedType) return;
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
setConnectorName(
selectedType === "formbricks" && selectedSurvey
? `${selectedSurvey.name} ${t("environments.unify.connection")}`
: defaultConnectorName[selectedType]
);
setCurrentStep("mapping");
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements
.filter((e) => !(UNSUPPORTED_CONNECTOR_ELEMENT_TYPES as readonly string[]).includes(e.type))
.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleBack = () => {
if (currentStep === "mapping") {
setCurrentStep("selectType");
setMappings([]);
setSourceFields([]);
}
};
const getSurveyMappings = () =>
Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
const handleHistoricalImports = async (connectorId: string) => {
const surveysToImport = Object.entries(importHistoricalBySurvey)
.filter(([surveyId, checked]) => checked && (elementIdsBySurvey[surveyId]?.length ?? 0) > 0)
.map(([surveyId]) => surveyId);
if (surveysToImport.length === 0) return;
setIsImporting(true);
let totalSuccesses = 0;
let totalFailures = 0;
let totalSkipped = 0;
for (const surveyId of surveysToImport) {
const importResult = await importHistoricalResponsesAction({
connectorId,
environmentId,
surveyId,
});
if (importResult?.data) {
totalSuccesses += importResult.data.successes;
totalFailures += importResult.data.failures;
totalSkipped += importResult.data.skipped;
} else {
toast.error(getFormattedErrorMessage(importResult));
}
}
setIsImporting(false);
if (totalSuccesses > 0 || totalFailures > 0) {
toast.success(
t("environments.unify.historical_import_complete", {
successes: totalSuccesses,
failures: totalFailures,
skipped: totalSkipped,
})
);
}
};
const handleCsvImport = async (connectorId: string) => {
setIsImporting(true);
const importResult = await importCsvDataAction({
connectorId,
environmentId,
csvData: csvParsedData,
});
setIsImporting(false);
if (importResult?.data) {
toast.success(
t("environments.unify.csv_import_complete", {
successes: importResult.data.successes,
failures: importResult.data.failures,
skipped: importResult.data.skipped,
})
);
} else {
toast.error(getFormattedErrorMessage(importResult));
}
};
const handleCreate = async () => {
if (!selectedType || !connectorName.trim()) return;
if (selectedType === "csv" && csvParsedData.length > 0) {
const errors = validateEnumMappings(mappings, csvParsedData);
if (errors.length > 0) {
setEnumValidationErrors(errors);
return;
}
setEnumValidationErrors([]);
}
setIsCreating(true);
const surveyMappings = getSurveyMappings();
const connectorId = await onCreateConnector({
name: connectorName.trim(),
type: selectedType,
surveyMappings: selectedType === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: selectedType !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
if (connectorId && selectedType === "formbricks") {
await handleHistoricalImports(connectorId);
}
if (connectorId && selectedType === "csv" && csvParsedData.length > 0) {
await handleCsvImport(connectorId);
}
setIsCreating(false);
resetForm();
onOpenChange(false);
};
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
const hasAnyElementSelections = Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
const isFormbricksValid = selectedType === "formbricks" && hasAnyElementSelections;
const isCsvValid = selectedType === "csv" && sourceFields.length > 0;
const handleLoadSourceFields = () => {
if (selectedType === "csv") {
const fields = parseCSVColumnsToFields("timestamp,customer_id,rating,feedback_text,category");
setSourceFields(fields);
}
};
return (
<>
<Button onClick={() => onOpenChange(true)} size="sm">
{t("environments.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("environments.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" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("environments.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
{(() => {
const entries = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, ids]) => ({
surveyId,
surveyName: surveys.find((s) => s.id === surveyId)?.name ?? surveyId,
responseCount: responseCountBySurvey[surveyId] ?? 0,
elementCount: ids.length,
importHistorical: importHistoricalBySurvey[surveyId] ?? false,
}))
.filter((e) => e.responseCount > 0);
if (entries.length === 0) return null;
return (
<AggregateImportSection
surveyEntries={entries}
onImportHistoricalChange={(surveyId, checked) => {
setImportHistoricalBySurvey((prev) => ({ ...prev, [surveyId]: checked }));
}}
t={t}
/>
);
})()}
</div>
)}
{currentStep === "mapping" && selectedType === "csv" && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="connectorName">{t("environments.unify.source_name")}</Label>
<Input
id="connectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
<div className="max-h-[55vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 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("environments.unify.invalid_enum_values", {
field: err.targetFieldName,
})}
</p>
<p>
{t("environments.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("environments.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={handleCreate}
disabled={
isCreating ||
isImporting ||
!connectorName.trim() ||
getCreateDisabled(selectedType, !!isFormbricksValid, isCsvValid, allRequiredMapped)
}>
{isCreating && <Loader2Icon className="mr-2 h-4 w-4 animate-spin" />}
{t("environments.unify.setup_connection")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
};

View File

@@ -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 { validateCsvFile } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/utils";
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 { 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.errors[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("environments.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("environments.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("environments.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("environments.unify.click_to_upload")}</span>{" "}
{t("environments.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("environments.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("environments.unify.load_sample_csv")}
</Button>
</div>
</div>
</div>
);
}

View File

@@ -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 { validateCsvFile } from "@/app/(app)/environments/[environmentId]/workspace/unify/sources/utils";
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";
interface CsvImportModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
connectorId: string;
environmentId: string;
onOpenEditConnector?: () => void;
}
export function CsvImportModal({
open,
onOpenChange,
connectorId,
environmentId,
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.errors[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, environmentId, csvData: parsedData });
setIsImporting(false);
if (result?.data) {
toast.success(
t("environments.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("environments.unify.import_csv_data")}</DialogTitle>
<DialogDescription>{t("environments.unify.upload_csv_data_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<Alert variant="info" size="small">
{t("environments.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("environments.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("environments.unify.click_to_upload")}</span>{" "}
{t("environments.unify.or_drag_and_drop")}
</p>
<p className="mt-1 text-xs text-slate-400">{t("environments.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("environments.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("environments.unify.importing_data")}
</>
) : (
t("environments.unify.import_rows", { count: rowCount })
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,293 @@
"use client";
import { FileSpreadsheetIcon, GlobeIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { TConnectorType, 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 { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import {
FEEDBACK_RECORD_FIELDS,
SAMPLE_CSV_COLUMNS,
TFieldMapping,
TSourceField,
TUnifySurvey,
} from "../types";
import { parseCSVColumnsToFields } from "../utils";
import { FormbricksSurveySelector } from "./formbricks-survey-selector";
import { MappingUI } from "./mapping-ui";
interface EditConnectorModalProps {
connector: TConnectorWithMappings | null;
open: boolean;
onOpenChange: (open: boolean) => void;
onUpdateConnector: (data: {
connectorId: string;
environmentId: string;
name: string;
surveyMappings?: { surveyId: string; elementIds: string[] }[];
fieldMappings?: TFieldMapping[];
}) => Promise<void>;
surveys: TUnifySurvey[];
onOpenCsvImport?: () => void;
}
const getConnectorIcon = (type: TConnectorType) => {
switch (type) {
case "formbricks":
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
case "csv":
return <FileSpreadsheetIcon className="h-5 w-5 text-slate-500" />;
default:
return <GlobeIcon className="h-5 w-5 text-slate-500" />;
}
};
const getConnectorTypeLabelKey = (type: TConnectorType): string => {
switch (type) {
case "formbricks":
return "environments.unify.formbricks_surveys";
case "csv":
return "environments.unify.csv_import";
default:
return type;
}
};
const groupMappingsBySurvey = (
mappings: { surveyId: string; elementId: string }[]
): Record<string, string[]> => {
const grouped: Record<string, string[]> = {};
for (const m of mappings) {
if (!grouped[m.surveyId]) grouped[m.surveyId] = [];
grouped[m.surveyId].push(m.elementId);
}
return grouped;
};
export const EditConnectorModal = ({
connector,
open,
onOpenChange,
onUpdateConnector,
surveys,
onOpenCsvImport,
}: EditConnectorModalProps) => {
const { t } = useTranslation();
const [connectorName, setConnectorName] = useState("");
const [mappings, setMappings] = useState<TFieldMapping[]>([]);
const [sourceFields, setSourceFields] = useState<TSourceField[]>([]);
const [selectedSurveyId, setSelectedSurveyId] = useState<string | null>(null);
const [elementIdsBySurvey, setElementIdsBySurvey] = useState<Record<string, string[]>>({});
const selectedElementIds = selectedSurveyId ? (elementIdsBySurvey[selectedSurveyId] ?? []) : [];
const requiredFields = FEEDBACK_RECORD_FIELDS.filter((f) => f.required);
const allRequiredMapped = requiredFields.every((field) =>
mappings.some((m) => m.targetFieldId === field.id && (m.sourceFieldId || m.staticValue))
);
useEffect(() => {
if (connector) {
setConnectorName(connector.name);
if (connector.type === "formbricks") {
const fbMappings = connector.formbricksMappings;
setSelectedSurveyId(fbMappings.length > 0 ? fbMappings[0].surveyId : null);
setElementIdsBySurvey(groupMappingsBySurvey(fbMappings));
setSourceFields([]);
setMappings([]);
} else if (connector.type === "csv") {
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,
}))
);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
} else {
setSourceFields([]);
setMappings([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
}
}
}, [connector]);
const resetForm = () => {
setConnectorName("");
setMappings([]);
setSourceFields([]);
setSelectedSurveyId(null);
setElementIdsBySurvey({});
};
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
resetForm();
}
onOpenChange(newOpen);
};
const handleSurveySelect = (surveyId: string | null) => {
setSelectedSurveyId(surveyId);
};
const handleElementToggle = (elementId: string) => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => {
const current = prev[selectedSurveyId] ?? [];
return {
...prev,
[selectedSurveyId]: current.includes(elementId)
? current.filter((id) => id !== elementId)
: [...current, elementId],
};
});
};
const handleSelectAllElements = (surveyId: string) => {
const survey = surveys.find((s) => s.id === surveyId);
if (survey) {
setElementIdsBySurvey((prev) => ({
...prev,
[surveyId]: survey.elements.map((e) => e.id),
}));
}
};
const handleDeselectAllElements = () => {
if (!selectedSurveyId) return;
setElementIdsBySurvey((prev) => ({
...prev,
[selectedSurveyId]: [],
}));
};
const handleUpdate = async () => {
if (!connector || !connectorName.trim()) return;
const surveyMappings = Object.entries(elementIdsBySurvey)
.filter(([, ids]) => ids.length > 0)
.map(([surveyId, elementIds]) => ({ surveyId, elementIds }));
await onUpdateConnector({
connectorId: connector.id,
environmentId: connector.environmentId,
name: connectorName.trim(),
surveyMappings:
connector.type === "formbricks" && surveyMappings.length > 0 ? surveyMappings : undefined,
fieldMappings: connector.type !== "formbricks" && mappings.length > 0 ? mappings : undefined,
});
handleOpenChange(false);
};
const saveChangesDisbaled = useMemo(() => {
if (!connector) return true;
if (!connectorName.trim()) return true;
if (connector.type === "formbricks") {
return !Object.values(elementIdsBySurvey).some((ids) => ids.length > 0);
}
if (connector.type === "csv") {
return !allRequiredMapped;
}
}, [allRequiredMapped, connector, connectorName, elementIdsBySurvey]);
if (!connector) return null;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>{t("environments.unify.edit_source_connection")}</DialogTitle>
<DialogDescription>{t("environments.unify.update_mapping_description")}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 p-3">
{getConnectorIcon(connector.type)}
<div>
<p className="text-sm font-medium text-slate-900">
{t(getConnectorTypeLabelKey(connector.type))}
</p>
<p className="text-xs text-slate-500">
{t("environments.unify.source_type_cannot_be_changed")}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="editConnectorName">{t("environments.unify.source_name")}</Label>
<Input
id="editConnectorName"
value={connectorName}
onChange={(e) => setConnectorName(e.target.value)}
placeholder={t("environments.unify.enter_name_for_source")}
/>
</div>
{connector.type === "formbricks" ? (
<div className="rounded-lg border border-slate-200 bg-slate-50 p-4">
<FormbricksSurveySelector
surveys={surveys}
selectedSurveyId={selectedSurveyId}
selectedElementIds={selectedElementIds}
onSurveySelect={handleSurveySelect}
onElementToggle={handleElementToggle}
onSelectAllElements={handleSelectAllElements}
onDeselectAllElements={handleDeselectAllElements}
/>
</div>
) : (
<div className="max-h-[40vh] overflow-y-auto rounded-lg border border-slate-200 bg-slate-50 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("environments.unify.import_feedback")}
</Button>
)}
<Button onClick={handleUpdate} disabled={saveChangesDisbaled}>
{t("environments.unify.save_changes")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -0,0 +1,237 @@
"use client";
import { CheckIcon, ChevronRightIcon, FileTextIcon, MessageSquareTextIcon, StarIcon } from "lucide-react";
import { Trans, useTranslation } from "react-i18next";
import { UNSUPPORTED_CONNECTOR_ELEMENT_TYPES } from "@formbricks/types/connector";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/constants";
import { getTSurveyElementTypeEnumName } from "@/modules/survey/lib/elements";
import { Badge } from "@/modules/ui/components/badge";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
import { TUnifySurvey } from "../types";
interface FormbricksSurveySelectorProps {
surveys: TUnifySurvey[];
selectedSurveyId: string | null;
selectedElementIds: string[];
onSurveySelect: (surveyId: string | null) => void;
onElementToggle: (elementId: string) => void;
onSelectAllElements: (surveyId: string) => void;
onDeselectAllElements: () => void;
}
const getElementIcon = (type: TSurveyElementTypeEnum) => {
switch (type) {
case "openText":
return <MessageSquareTextIcon className="h-4 w-4 text-slate-500" />;
case "rating":
case "nps":
return <StarIcon className="h-4 w-4 text-amber-500" />;
default:
return <FileTextIcon className="h-4 w-4 text-slate-500" />;
}
};
const isUnsupportedType = (type: TSurveyElementTypeEnum): boolean => {
return UNSUPPORTED_CONNECTOR_ELEMENT_TYPES.includes(type);
};
export const FormbricksSurveySelector = ({
surveys,
selectedSurveyId,
selectedElementIds,
onSurveySelect,
onElementToggle,
onSelectAllElements,
onDeselectAllElements,
}: FormbricksSurveySelectorProps) => {
const { t } = useTranslation();
const selectedSurvey = surveys.find((s) => s.id === selectedSurveyId);
const supportedElements = selectedSurvey?.elements.filter((e) => !isUnsupportedType(e.type)) ?? [];
const allSupportedSelected =
supportedElements.length > 0 && supportedElements.every((e) => selectedElementIds.includes(e.id));
const handleSurveyClick = (survey: TUnifySurvey) => {
if (selectedSurveyId !== survey.id) {
onSurveySelect(survey.id);
}
};
const handleSelectAllSupported = (surveyId: string) => {
onSelectAllElements(surveyId);
};
const getStatusBadge = (status: TUnifySurvey["status"]) => {
switch (status) {
case "active":
return <Badge text={t("environments.unify.status_active")} type="success" size="tiny" />;
case "paused":
return <Badge text={t("environments.unify.status_paused")} type="warning" size="tiny" />;
case "draft":
return <Badge text={t("environments.unify.status_draft")} type="gray" size="tiny" />;
case "completed":
return <Badge text={t("environments.unify.status_completed")} type="gray" size="tiny" />;
default:
return null;
}
};
const getSupportedElementCount = (survey: TUnifySurvey) =>
survey.elements.filter((e) => !isUnsupportedType(e.type)).length;
const getElementButtonClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "cursor-not-allowed border-slate-100 bg-slate-50 opacity-50";
if (isSelected) return "border-green-300 bg-green-50";
return "border-slate-200 bg-white hover:border-slate-300";
};
const getCheckboxClassName = (unsupported: boolean, isSelected: boolean): string => {
if (unsupported) return "border border-slate-200 bg-slate-100";
if (isSelected) return "bg-green-500 text-white";
return "border border-slate-300 bg-white";
};
const renderElementPanel = () => {
if (!selectedSurvey) {
return (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.select_a_survey_to_see_questions")}</p>
</div>
);
}
if (selectedSurvey.elements.length === 0) {
return (
<div className="flex flex-1 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.survey_has_no_questions")}</p>
</div>
);
}
return (
<div className="space-y-2 overflow-y-auto pr-1">
<TooltipProvider delayDuration={200}>
{selectedSurvey.elements.map((element) => {
const isSelected = selectedElementIds.includes(element.id);
const unsupported = isUnsupportedType(element.type);
const button = (
<button
key={element.id}
type="button"
disabled={unsupported}
onClick={() => onElementToggle(element.id)}
className={`flex w-full items-center gap-3 rounded-lg border p-3 text-left transition-colors ${getElementButtonClassName(unsupported, isSelected)}`}>
<div
className={`flex h-5 w-5 items-center justify-center rounded ${getCheckboxClassName(unsupported, isSelected)}`}>
{isSelected && !unsupported && <CheckIcon className="h-3 w-3" />}
</div>
<div className="flex items-center gap-2">{getElementIcon(element.type)}</div>
<div className="flex-1">
<p className={`text-sm ${unsupported ? "text-slate-400" : "text-slate-900"}`}>
{element.headline}
</p>
<span className={`text-xs ${unsupported ? "text-slate-300" : "text-slate-500"}`}>
{getTSurveyElementTypeEnumName(element.type, t) ?? element.type}
</span>
</div>
</button>
);
if (unsupported) {
return (
<Tooltip key={element.id}>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent>{t("environments.unify.question_type_not_supported")}</TooltipContent>
</Tooltip>
);
}
return button;
})}
</TooltipProvider>
{selectedElementIds.length > 0 && (
<div className="mt-4 rounded-lg border border-blue-200 bg-blue-50 p-3">
<p className="text-xs text-blue-700">
<Trans
i18nKey={
selectedElementIds.length === 1
? "environments.unify.question_selected"
: "environments.unify.questions_selected"
}
values={{ count: selectedElementIds.length }}
components={{ strong: <strong /> }}
/>
</p>
</div>
)}
</div>
);
};
return (
<div className="grid h-[50vh] grid-cols-2 gap-6">
{/* Left: Survey List */}
<div className="flex flex-col gap-3 overflow-hidden">
<h4 className="shrink-0 text-sm font-medium text-slate-700">
{t("environments.unify.select_survey")}
</h4>
<div className="space-y-2 overflow-y-auto pr-1">
{surveys.length === 0 ? (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 bg-slate-50">
<p className="text-sm text-slate-500">{t("environments.unify.no_surveys_found")}</p>
</div>
) : (
surveys.map((survey) => {
const isSelected = selectedSurveyId === survey.id;
return (
<div key={survey.id}>
<button
type="button"
onClick={() => handleSurveyClick(survey)}
className={`flex w-full items-center gap-3 rounded-lg border bg-white p-3 text-left transition-colors ${
isSelected ? "border-brand-dark bg-slate-50" : "border-slate-200 hover:border-slate-300"
}`}>
<div className="min-w-0 flex-1 space-y-1">
<div>{getStatusBadge(survey.status)}</div>
<span className="block truncate text-sm font-medium text-slate-900">{survey.name}</span>
<p className="text-xs text-slate-500">
{t("environments.unify.n_supported_questions", {
count: getSupportedElementCount(survey),
})}
</p>
</div>
{isSelected && <ChevronRightIcon className="text-brand-dark h-5 w-5 shrink-0" />}
</button>
</div>
);
})
)}
</div>
</div>
{/* Right: Element Selection */}
<div className="flex flex-col gap-3 overflow-hidden">
<div className="flex shrink-0 items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">{t("environments.unify.select_questions")}</h4>
{selectedSurvey && supportedElements.length > 0 && (
<button
type="button"
onClick={() =>
allSupportedSelected ? onDeselectAllElements() : handleSelectAllSupported(selectedSurvey.id)
}
className="text-xs text-slate-500 hover:text-slate-700">
{allSupportedSelected
? t("environments.unify.deselect_all")
: t("environments.unify.select_all")}
</button>
)}
</div>
{renderElementPanel()}
</div>
</div>
);
};

View File

@@ -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("environments.unify.enum")}</span>
</div>
{mappedSourceField && !mapping?.staticValue ? (
<div className="flex items-center gap-1">
<span className="text-xs text-green-700">&larr; {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("environments.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">
= &ldquo;{mapping.staticValue}&rdquo;
</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("environments.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("environments.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("environments.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("environments.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("environments.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>
);
};

View File

@@ -0,0 +1,148 @@
"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("environments.unify.csv_columns")
: t("environments.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("environments.unify.click_load_sample_csv")
: t("environments.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("environments.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("environments.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("environments.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="border-brand-dark rounded-md border 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>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,39 @@
import { notFound } from "next/navigation";
import { getConnectorsWithMappings } from "@/lib/connector/service";
import { getSurveys } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { ConnectorsSection } from "./components/connectors-page-client";
import { transformToUnifySurvey } from "./lib";
export default async function UnifySourcesPage(props: { params: Promise<{ environmentId: string }> }) {
const t = await getTranslate();
const params = await props.params;
const { isOwner, isManager, hasReadAccess, hasReadWriteAccess, hasManageAccess, session } =
await getEnvironmentAuth(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const hasAccess = isOwner || isManager || hasReadAccess || hasReadWriteAccess || hasManageAccess;
if (!hasAccess) {
return notFound();
}
const [connectors, surveys] = await Promise.all([
getConnectorsWithMappings(params.environmentId),
getSurveys(params.environmentId),
]);
const unifySurveys = surveys.map(transformToUnifySurvey);
return (
<ConnectorsSection
environmentId={params.environmentId}
initialConnectors={connectors}
initialSurveys={unifySurveys}
/>
);
}

View File

@@ -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("environments.unify.csv_at_least_one_row") })
.max(MAX_CSV_VALUES.RECORDS, {
message: t("environments.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("environments.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("environments.unify.csv_empty_column_headers"),
});
}
});
export type TFeedbackCSVData = z.infer<ReturnType<typeof createFeedbackCSVDataSchema>>;
export type TCreateConnectorStep = "selectType" | "mapping";

View File

@@ -0,0 +1,111 @@
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 and csv options", () => {
const options = getConnectorOptions(mockT as never);
expect(options).toHaveLength(2);
expect(options[0].id).toBe("formbricks");
expect(options[1].id).toBe("csv");
});
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("environments.unify.formbricks_surveys");
expect(options[0].description).toBe("environments.unify.source_connect_formbricks_description");
expect(options[1].name).toBe("environments.unify.csv_import");
expect(options[1].description).toBe("environments.unify.source_connect_csv_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: "environments.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: "environments.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: "environments.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: "environments.unify.csv_files_only" });
});
});

View File

@@ -0,0 +1,93 @@
import { TFunction } from "i18next";
import { THubFieldType } from "@formbricks/types/connector";
import { FEEDBACK_RECORD_FIELDS, MAX_CSV_VALUES, TFieldMapping, TSourceField } from "./types";
export interface TConnectorOption {
id: string;
name: string;
description: string;
disabled: boolean;
badge?: { text: string; type: "success" | "gray" | "warning" };
}
export const getConnectorOptions = (t: TFunction): TConnectorOption[] => [
{
id: "formbricks",
name: t("environments.unify.formbricks_surveys"),
description: t("environments.unify.source_connect_formbricks_description"),
disabled: false,
},
{
id: "csv",
name: t("environments.unify.csv_import"),
description: t("environments.unify.source_connect_csv_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("environments.unify.csv_files_only") };
}
if (file.type && file.type !== "text/csv" && !file.type.includes("csv")) {
return { valid: false, error: t("environments.unify.csv_files_only") };
}
if (file.size > MAX_CSV_VALUES.FILE_SIZE) {
return { valid: false, error: t("environments.unify.csv_file_too_large") };
}
return { valid: true };
};

View File

@@ -21,7 +21,6 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
import { parseRecallInfo } from "@/lib/utils/recall";
import { truncateText } from "@/lib/utils/strings";
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
let result: string[] = [];
@@ -257,16 +256,10 @@ const processElementResponse = (
const selectedChoiceIds = responseValue as string[];
return element.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
.map((choice) => choice.imageUrl)
.join("\n");
}
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
return responseValue
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
.join("; ");
}
return processResponseData(responseValue);
};
@@ -375,7 +368,7 @@ const buildNotionPayloadProperties = (
responses[resp] = (pictureElement as any)?.choices
.filter((choice) => selectedChoiceIds.includes(choice.id))
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
.map((choice) => choice.imageUrl);
}
});

View File

@@ -8,6 +8,7 @@ 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 } from "@/lib/constants";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { getIntegrations } from "@/lib/integration/service";
@@ -15,11 +16,9 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
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";
@@ -97,15 +96,12 @@ export const POST = async (request: Request) => {
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
data: {
...response,
data: resolvedResponseData,
survey: {
title: survey.name,
type: survey.type,
@@ -136,20 +132,24 @@ export const POST = async (request: Request) => {
);
}
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
});
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, environmentId);
} 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(environmentId),

View File

@@ -1,6 +1,5 @@
import { google } from "googleapis";
import { getServerSession } from "next-auth";
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
import { responses } from "@/app/lib/api/response";
import {
GOOGLE_SHEETS_CLIENT_ID,
@@ -9,7 +8,7 @@ import {
WEBAPP_URL,
} from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
import { createOrUpdateIntegration } from "@/lib/integration/service";
import { authOptions } from "@/modules/auth/lib/authOptions";
export const GET = async (req: Request) => {
@@ -43,39 +42,33 @@ export const GET = async (req: Request) => {
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
if (!code) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
let key;
let userEmail;
if (code) {
const token = await oAuth2Client.getToken(code);
key = token.res?.data;
// Set credentials using the provided token
oAuth2Client.setCredentials({
access_token: key.access_token,
});
// Fetch user's email
const oauth2 = google.oauth2({
auth: oAuth2Client,
version: "v2",
});
const userInfo = await oauth2.userinfo.get();
userEmail = userInfo.data.email;
}
const token = await oAuth2Client.getToken(code);
const key = token.res?.data;
if (!key) {
return Response.redirect(
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
);
}
oAuth2Client.setCredentials({ access_token: key.access_token });
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
const userInfo = await oauth2.userinfo.get();
const userEmail = userInfo.data.email;
if (!userEmail) {
return responses.internalServerErrorResponse("Failed to get user email");
}
const integrationType = "googleSheets" as const;
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
const googleSheetIntegration = {
type: integrationType,
type: "googleSheets" as "googleSheets",
environment: environmentId,
config: {
key,
data: existingConfig?.data ?? [],
data: [],
email: userEmail,
},
};

View File

@@ -10,7 +10,6 @@ import {
TJsEnvironmentStateSurvey,
} from "@formbricks/types/js";
import { validateInputs } from "@/lib/utils/validate";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
/**
@@ -178,14 +177,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
overlay: environmentData.project.overlay,
placement: environmentData.project.placement,
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
styling: resolveStorageUrlsInObject(environmentData.project.styling),
styling: environmentData.project.styling,
},
},
organization: {
id: environmentData.project.organization.id,
billing: environmentData.project.organization.billing,
},
surveys: resolveStorageUrlsInObject(transformedSurveys),
surveys: transformedSurveys,
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
};
} catch (error) {

View File

@@ -50,7 +50,7 @@ export const GET = withV1ApiWrapper({
{
environmentId: params.environmentId,
url: req.url,
validationError: cuidValidation.error.issues[0]?.message,
validationError: cuidValidation.error.errors[0]?.message,
},
"Invalid CUID v1 format detected"
);

View File

@@ -44,10 +44,13 @@ const validateResponse = (
...responseUpdateInput.data,
};
const isFinished = responseUpdateInput.finished ?? false;
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
isFinished,
survey.questions
);

View File

@@ -41,6 +41,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
survey.blocks,
responseInputData.data,
responseInputData.language ?? "en",
responseInputData.finished,
survey.questions
);

View File

@@ -6,138 +6,140 @@ export const GET = async (req: NextRequest) => {
let brandColor = req.nextUrl.searchParams.get("brandColor");
return new ImageResponse(
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
}}>
(
<div
style={{
display: "flex",
flexDirection: "column",
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
width: "100%",
height: "100%",
alignItems: "center",
backgroundColor: "white",
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "100%",
justifyContent: "space-between",
}}>
<div
style={{
display: "flex",
flexDirection: "column",
width: "80%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3.25rem",
position: "absolute",
left: "3rem",
top: "0.75rem",
opacity: 0.2,
transform: "rotate(356deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "84%",
height: "60%",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "3rem",
position: "absolute",
top: "1.25rem",
left: "3.25rem",
borderWidth: "2px",
opacity: 0.6,
transform: "rotate(357deg)",
}}></div>
<div
style={{
display: "flex",
flexDirection: "column",
width: "85%",
height: "67%",
alignItems: "center",
backgroundColor: "white",
borderRadius: "0.75rem",
marginTop: "2rem",
position: "absolute",
top: "2.3rem",
left: "3.5rem",
transform: "rotate(360deg)",
}}>
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "column",
paddingLeft: "2rem",
paddingRight: "2rem",
width: "100%",
justifyContent: "space-between",
}}>
<h2
<div
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
paddingLeft: "2rem",
paddingRight: "2rem",
}}>
{name}
</h2>
<h2
style={{
display: "flex",
flexDirection: "column",
fontSize: "2rem",
fontWeight: "700",
letterSpacing: "-0.025em",
color: "#0f172a",
textAlign: "left",
marginTop: "3.75rem",
}}>
{name}
</h2>
</div>
</div>
</div>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
borderRadius: "1rem",
position: "absolute",
right: "-0.5rem",
marginTop: "0.5rem",
}}>
Begin!
<div
content=""
style={{
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
height: "4.5rem",
width: "9.5rem",
opacity: 0.5,
}}></div>
</div>
<div
style={{
display: "flex",
borderRadius: "1rem",
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
}}>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "0.75rem",
border: "1px solid transparent",
backgroundColor: brandColor ?? "#000",
fontSize: "1.5rem",
color: "white",
height: "4.5rem",
width: "9.5rem",
}}>
Begin!
</div>
</div>
</div>
</div>
</div>
</div>
</div>,
),
{
width: 800,
height: 400,

View File

@@ -6,7 +6,7 @@ import {
} from "@formbricks/types/integration/slack";
import { responses } from "@/app/lib/api/response";
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
@@ -56,7 +56,6 @@ export const GET = withV1ApiWrapper({
code,
client_id: SLACK_CLIENT_ID,
client_secret: SLACK_CLIENT_SECRET,
redirect_uri: SLACK_REDIRECT_URI,
};
const formBody: string[] = [];
for (const property in formData) {

View File

@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
async function fetchAndAuthorizeResponse(
@@ -57,10 +57,7 @@ export const GET = withV1ApiWrapper({
}
return {
response: responses.successResponse({
...result.response,
data: resolveStorageUrlsInObject(result.response.data),
}),
response: responses.successResponse(result.response),
};
} catch (error) {
return {
@@ -149,6 +146,7 @@ export const PUT = withV1ApiWrapper({
result.survey.blocks,
responseUpdate.data,
responseUpdate.language ?? "en",
responseUpdate.finished,
result.survey.questions
);
@@ -192,7 +190,7 @@ export const PUT = withV1ApiWrapper({
}
return {
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
response: responses.successResponse(updated),
};
} catch (error) {
return {

View File

@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
import { validateFileUploads } from "@/modules/storage/utils";
import {
createResponseWithQuotaEvaluation,
getResponses,
@@ -54,9 +54,7 @@ export const GET = withV1ApiWrapper({
allResponses.push(...environmentResponses);
}
return {
response: responses.successResponse(
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
),
response: responses.successResponse(allResponses),
};
} catch (error) {
if (error instanceof DatabaseError) {
@@ -157,6 +155,7 @@ export const POST = withV1ApiWrapper({
surveyResult.survey.blocks,
responseInput.data,
responseInput.language ?? "en",
responseInput.finished,
surveyResult.survey.questions
);

View File

@@ -6,7 +6,7 @@ import { DatabaseError } from "@formbricks/types/errors";
import { validateInputs } from "@/lib/utils/validate";
export const deleteSurvey = async (surveyId: string) => {
validateInputs([surveyId, z.cuid2()]);
validateInputs([surveyId, z.string().cuid2()]);
try {
const deletedSurvey = await prisma.survey.delete({

View File

@@ -16,7 +16,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
const fetchAndAuthorizeSurvey = async (
surveyId: string,
@@ -59,18 +58,16 @@ export const GET = withV1ApiWrapper({
if (shouldTransformToQuestions) {
return {
response: responses.successResponse(
resolveStorageUrlsInObject({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
})
),
response: responses.successResponse({
...result.survey,
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
blocks: [],
}),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
response: responses.successResponse(result.survey),
};
} catch (error) {
return {
@@ -205,12 +202,12 @@ export const PUT = withV1ApiWrapper({
};
return {
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
response: responses.successResponse(surveyWithQuestions),
};
}
return {
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
response: responses.successResponse(updatedSurvey),
};
} catch (error) {
return {

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