mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-14 02:12:43 -05:00
Compare commits
14 Commits
fix-editor
...
epic/conne
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cffeb0513e | ||
|
|
077a9934ad | ||
|
|
1ed8d8076e | ||
|
|
8b048c3105 | ||
|
|
b2705a4f8f | ||
|
|
e867caa373 | ||
|
|
de79b58648 | ||
|
|
04d528b9b8 | ||
|
|
c815b11015 | ||
|
|
1e7830d850 | ||
|
|
77cd1e9bd1 | ||
|
|
e665227437 | ||
|
|
75e71e39bc | ||
|
|
337aedf463 |
@@ -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 #
|
||||
################
|
||||
|
||||
42
.github/workflows/translation-check.yml
vendored
42
.github/workflows/translation-check.yml
vendored
@@ -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
2
.husky/post-checkout
Normal file
@@ -0,0 +1,2 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
prettier --write ./branch.json
|
||||
@@ -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
|
||||
@@ -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.14",
|
||||
"@storybook/addon-links": "10.2.14",
|
||||
"@storybook/addon-onboarding": "10.2.14",
|
||||
"@storybook/react-vite": "10.2.14",
|
||||
"@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.14",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.1.11",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.2.14"
|
||||
"@storybook/addon-docs": "10.1.11"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
const baseConfig = require("../../.prettierrc.js");
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
};
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}`}>
|
||||
|
||||
@@ -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`}>
|
||||
|
||||
@@ -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")} />
|
||||
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
PanelLeftCloseIcon,
|
||||
PanelLeftOpenIcon,
|
||||
RocketIcon,
|
||||
ShapesIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
@@ -99,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"),
|
||||
@@ -107,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") ||
|
||||
@@ -115,7 +116,13 @@ export const MainNavigation = ({
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
name: t("common.unify"),
|
||||
href: `/environments/${environment.id}/workspace/unify`,
|
||||
icon: ShapesIcon,
|
||||
isActive: pathname?.includes("/unify") && !pathname?.includes("/analyze"),
|
||||
},
|
||||
{
|
||||
name: t("common.configure"),
|
||||
href: `/environments/${environment.id}/workspace/general`,
|
||||
icon: Cog,
|
||||
isActive: pathname?.includes("/project"),
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
@@ -107,31 +106,3 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.number().int().min(1).max(100),
|
||||
offset: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
.schema(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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}%
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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")}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
.schema(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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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">← {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">
|
||||
= “{mapping.staticValue}”
|
||||
</span>
|
||||
<RemoveMappingButton onClick={onRemoveMapping} variant="blue" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEditingStatic && !hasMapping && (
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="text"
|
||||
value={customValue}
|
||||
onChange={(e) => setCustomValue(e.target.value)}
|
||||
placeholder={
|
||||
field.exampleStaticValues
|
||||
? `e.g., ${field.exampleStaticValues[0]}`
|
||||
: t("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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { transformToUnifySurvey } from "./lib";
|
||||
|
||||
vi.mock("@formbricks/types/surveys/validation", () => ({
|
||||
getTextContent: (str: string) => str,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/i18n/utils", () => ({
|
||||
getLocalizedValue: (val: Record<string, string>, _lang: string) => val?.default ?? "",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
getElementsFromBlocks: (blocks: Array<{ elements: unknown[] }>) =>
|
||||
blocks.flatMap((block) => block.elements),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
recallToHeadline: (headline: Record<string, string>) => headline,
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-02-24T10:00:00.000Z");
|
||||
|
||||
const createMockSurvey = (overrides: Partial<TSurvey> = {}): TSurvey =>
|
||||
({
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
status: "inProgress",
|
||||
createdAt: NOW,
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "What do you think?" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: { default: "How likely to recommend?" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}) as unknown as TSurvey;
|
||||
|
||||
describe("transformToUnifySurvey", () => {
|
||||
test("transforms a survey with basic elements", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey());
|
||||
|
||||
expect(result).toEqual({
|
||||
id: "survey-1",
|
||||
name: "Test Survey",
|
||||
status: "active",
|
||||
createdAt: NOW,
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: "What do you think?",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-nps",
|
||||
type: TSurveyElementTypeEnum.NPS,
|
||||
headline: "How likely to recommend?",
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("filters out CTA elements", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-text",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Feedback" },
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "el-cta",
|
||||
type: TSurveyElementTypeEnum.CTA,
|
||||
headline: { default: "Click here" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
|
||||
expect(result.elements).toHaveLength(1);
|
||||
expect(result.elements[0].id).toBe("el-text");
|
||||
});
|
||||
|
||||
test("defaults required to false when not set", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.Rating,
|
||||
headline: { default: "Rate us" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements[0].required).toBe(false);
|
||||
});
|
||||
|
||||
test("falls back to 'Untitled' when headline is empty", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "" },
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements[0].headline).toBe("Untitled");
|
||||
});
|
||||
|
||||
describe("mapSurveyStatus", () => {
|
||||
test("maps 'inProgress' to 'active'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "inProgress" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("active");
|
||||
});
|
||||
|
||||
test("maps 'paused' to 'paused'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "paused" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("paused");
|
||||
});
|
||||
|
||||
test("maps 'draft' to 'draft'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "draft" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
|
||||
test("maps 'completed' to 'completed'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "completed" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("completed");
|
||||
});
|
||||
|
||||
test("maps unknown status to 'draft'", () => {
|
||||
const result = transformToUnifySurvey(createMockSurvey({ status: "archived" } as Partial<TSurvey>));
|
||||
expect(result.status).toBe("draft");
|
||||
});
|
||||
});
|
||||
|
||||
test("handles multiple blocks", () => {
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: [
|
||||
{
|
||||
id: "el-1",
|
||||
type: TSurveyElementTypeEnum.OpenText,
|
||||
headline: { default: "Q1" },
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
elements: [
|
||||
{ id: "el-2", type: TSurveyElementTypeEnum.Rating, headline: { default: "Q2" }, required: false },
|
||||
],
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements).toHaveLength(2);
|
||||
expect(result.elements[0].id).toBe("el-1");
|
||||
expect(result.elements[1].id).toBe("el-2");
|
||||
});
|
||||
|
||||
test("handles empty blocks", () => {
|
||||
const survey = createMockSurvey({ blocks: [] } as Partial<TSurvey>);
|
||||
const result = transformToUnifySurvey(survey);
|
||||
expect(result.elements).toEqual([]);
|
||||
});
|
||||
|
||||
test("preserves all element types except CTA", () => {
|
||||
const elementTypes = [
|
||||
TSurveyElementTypeEnum.OpenText,
|
||||
TSurveyElementTypeEnum.NPS,
|
||||
TSurveyElementTypeEnum.Rating,
|
||||
TSurveyElementTypeEnum.MultipleChoiceSingle,
|
||||
TSurveyElementTypeEnum.MultipleChoiceMulti,
|
||||
TSurveyElementTypeEnum.Date,
|
||||
TSurveyElementTypeEnum.Consent,
|
||||
TSurveyElementTypeEnum.Matrix,
|
||||
TSurveyElementTypeEnum.Ranking,
|
||||
TSurveyElementTypeEnum.PictureSelection,
|
||||
TSurveyElementTypeEnum.ContactInfo,
|
||||
TSurveyElementTypeEnum.Address,
|
||||
TSurveyElementTypeEnum.FileUpload,
|
||||
TSurveyElementTypeEnum.Cal,
|
||||
TSurveyElementTypeEnum.CTA,
|
||||
];
|
||||
|
||||
const survey = createMockSurvey({
|
||||
blocks: [
|
||||
{
|
||||
elements: elementTypes.map((type, i) => ({
|
||||
id: `el-${i.toString()}`,
|
||||
type,
|
||||
headline: { default: `Question ${i.toString()}` },
|
||||
required: false,
|
||||
})),
|
||||
},
|
||||
],
|
||||
} as Partial<TSurvey>);
|
||||
|
||||
const result = transformToUnifySurvey(survey);
|
||||
const resultTypes = result.elements.map((e) => e.type);
|
||||
|
||||
expect(resultTypes).not.toContain(TSurveyElementTypeEnum.CTA);
|
||||
expect(result.elements).toHaveLength(elementTypes.length - 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { TSurveyElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getTextContent } from "@formbricks/types/surveys/validation";
|
||||
import { getLocalizedValue } from "@/lib/i18n/utils";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { TUnifySurvey, TUnifySurveyElement } from "./types";
|
||||
|
||||
const getElementHeadline = (element: TSurveyElement, survey: TSurvey): string => {
|
||||
return (
|
||||
getTextContent(
|
||||
getLocalizedValue(recallToHeadline(element.headline, survey, false, "default"), "default")
|
||||
) || "Untitled"
|
||||
);
|
||||
};
|
||||
|
||||
const mapSurveyStatus = (status: string): TUnifySurvey["status"] => {
|
||||
switch (status) {
|
||||
case "inProgress":
|
||||
return "active";
|
||||
case "paused":
|
||||
return "paused";
|
||||
case "draft":
|
||||
return "draft";
|
||||
case "completed":
|
||||
return "completed";
|
||||
default:
|
||||
return "draft";
|
||||
}
|
||||
};
|
||||
|
||||
export const transformToUnifySurvey = (survey: TSurvey): TUnifySurvey => {
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
|
||||
const unifySurveyElements: TUnifySurveyElement[] = elements
|
||||
.filter((el) => el.type !== TSurveyElementTypeEnum.CTA)
|
||||
.map((el) => ({
|
||||
id: el.id,
|
||||
type: el.type,
|
||||
headline: getElementHeadline(el, survey),
|
||||
required: el.required ?? false,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: survey.id,
|
||||
name: survey.name,
|
||||
status: mapSurveyStatus(survey.status),
|
||||
elements: unifySurveyElements,
|
||||
createdAt: survey.createdAt,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -56,7 +55,7 @@ export const GET = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||
response: responses.successResponse(surveysWithQuestions),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -2,11 +2,10 @@ import { Prisma, WebhookSource } from "@prisma/client";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
|
||||
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
@@ -24,10 +23,6 @@ vi.mock("@/lib/crypto", () => ({
|
||||
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
describe("createWebhook", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -80,41 +75,6 @@ describe("createWebhook", () => {
|
||||
expect(result).toEqual(createdWebhook);
|
||||
});
|
||||
|
||||
test("should call validateWebhookUrl with the provided URL", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "https://example.com",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
};
|
||||
|
||||
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
|
||||
|
||||
await createWebhook(webhookInput);
|
||||
|
||||
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
|
||||
});
|
||||
|
||||
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
|
||||
const webhookInput: TWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
name: "Test Webhook",
|
||||
url: "http://169.254.169.254/latest/meta-data/",
|
||||
source: "user",
|
||||
triggers: ["responseCreated"],
|
||||
surveyIds: ["survey1"],
|
||||
};
|
||||
|
||||
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
|
||||
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
|
||||
);
|
||||
|
||||
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
|
||||
expect(prisma.webhook.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
|
||||
const invalidWebhookInput = {
|
||||
environmentId: "test-env-id",
|
||||
|
||||
@@ -6,11 +6,9 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
|
||||
import { ITEMS_PER_PAGE } from "@/lib/constants";
|
||||
import { generateWebhookSecret } from "@/lib/crypto";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
|
||||
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
await validateWebhookUrl(webhookInput.url);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
@@ -112,6 +112,7 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -131,11 +131,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -183,11 +185,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -229,11 +233,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on thrown error", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -285,11 +291,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -339,11 +347,13 @@ describe("withV1ApiWrapper", () => {
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -366,8 +376,9 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("handles client-side API routes without authentication", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
@@ -399,8 +410,9 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -423,8 +435,9 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -449,11 +462,13 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -257,7 +257,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||
});
|
||||
@@ -314,19 +313,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for pretty URL survey routes", () => {
|
||||
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for malformed pretty URL survey routes", () => {
|
||||
expect(isPublicDomainRoute("/p/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/p")).toBe(false);
|
||||
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||
@@ -389,8 +375,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||
});
|
||||
@@ -406,7 +390,6 @@ describe("endpoint-validator", () => {
|
||||
test("should allow public routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
@@ -443,8 +426,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||
});
|
||||
@@ -459,8 +440,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle paths with query parameters and fragments", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||
});
|
||||
@@ -471,7 +450,6 @@ describe("endpoint-validator", () => {
|
||||
describe("URL parsing edge cases", () => {
|
||||
test("should handle paths with query parameters", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||
@@ -480,14 +458,12 @@ describe("endpoint-validator", () => {
|
||||
test("should handle paths with fragments", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle trailing slashes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||
isManagementApi: true,
|
||||
@@ -502,9 +478,6 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle nested client API routes", () => {
|
||||
@@ -556,7 +529,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle special characters in survey IDs", () => {
|
||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -564,7 +536,6 @@ describe("endpoint-validator", () => {
|
||||
test("should properly validate malicious or injection-like URLs", () => {
|
||||
// SQL injection-like attempts
|
||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
||||
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
@@ -572,12 +543,10 @@ describe("endpoint-validator", () => {
|
||||
|
||||
// Path traversal attempts
|
||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||
|
||||
// XSS-like attempts
|
||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
@@ -587,7 +556,6 @@ describe("endpoint-validator", () => {
|
||||
test("should handle URL encoding", () => {
|
||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||
isManagementApi: true,
|
||||
@@ -623,7 +591,6 @@ describe("endpoint-validator", () => {
|
||||
// These should not match due to case sensitivity
|
||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
|
||||
@@ -7,7 +7,6 @@ const PUBLIC_ROUTES = {
|
||||
SURVEY_ROUTES: [
|
||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||
],
|
||||
|
||||
// API routes accessible from public domain
|
||||
|
||||
@@ -114,6 +114,7 @@ checksums:
|
||||
common/app_survey: f076d131d20bfdadb35fba29c8275232
|
||||
common/apply_filters: 6543c1e80038b3da0f4a42848d08d4d1
|
||||
common/are_you_sure: 6d5cd13628a7887711fd0c29f1123652
|
||||
common/ask: 24150ae04c60dcd8688d93a8a3a2d238
|
||||
common/attributes: 86d0ae6fea0fbb119722ed3841f8385a
|
||||
common/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
common/billing: b01dbdd049ebbd4a349fa64d6ce65a3b
|
||||
@@ -136,7 +137,7 @@ checksums:
|
||||
common/code: 343bc5386149b97cece2b093c39034b2
|
||||
common/collapse_rows: 24988527f9180f37aa55d2aa183ccb21
|
||||
common/completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
common/configuration: 923ec0502721489202f6222dd4107163
|
||||
common/configure: e3ab18ebb36c218cd4897c620f5809ac
|
||||
common/confirm: 90930b51154032f119fa75c1bd422d8b
|
||||
common/connect: 8778ee245078a8be4a2ce855c8c56edc
|
||||
common/connect_formbricks: a9dd747575e7e035da69251366df6f95
|
||||
@@ -148,12 +149,9 @@ checksums:
|
||||
common/copy: 627c00d2c850b9b45f7341a6ac01b6bb
|
||||
common/copy_code: 704c13d9bc01caad29a1cf3179baa111
|
||||
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
||||
common/count_attributes: 48805e836a9b50f9635ad00fed953058
|
||||
common/count_contacts: 9f71d503455264f1eec1ae58894cf143
|
||||
common/count_members: 31ce64ca63fdf95e02ab5543b6e2f717
|
||||
common/count_questions: a7a34376a01eda781381fe7544541293
|
||||
common/count_responses: 437e022825c7a08481d8f7e56926742d
|
||||
common/count_selections: a1ec41682b9a7d8601c3905dfba34e16
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -167,7 +165,6 @@ checksums:
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
|
||||
common/description: e17686a22ffad04cc7bb70524ed4478b
|
||||
common/dev_env: e650911d5e19ba256358e0cda154c005
|
||||
common/development: 85211dbb918bda7a6e87649dcfc1b17a
|
||||
@@ -176,6 +173,7 @@ checksums:
|
||||
common/disallow: 01c8ed3ce545ed836d3ccffc562c8a0c
|
||||
common/discard: de83a114a79d086e372c43dbfe9f47b4
|
||||
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
|
||||
common/distribute: 0b702c85b5d4069d8367cb461c2ee0b1
|
||||
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
common/domain: 402d46965eacc3af4c5df92e53e95712
|
||||
@@ -183,11 +181,10 @@ checksums:
|
||||
common/download: 56b7d0834952b39ee394b44bd8179178
|
||||
common/draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
common/duplicate: 27756566785c2b8463e21582c4bb619b
|
||||
common/duplicate_copy: 68d2201918610ca87c2914b61dc8010f
|
||||
common/duplicate_copy_number: 083cfffd294672043dcbcc4c3dfeac6a
|
||||
common/e_commerce: b9584e7d0449a6d1b0c182d7ff14061e
|
||||
common/edit: eee7f39ff90b18852afc1671f21fbaa9
|
||||
common/email: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
common/enable: 463972a7a95f50f3105d09b92508f2cd
|
||||
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
|
||||
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
|
||||
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
|
||||
@@ -197,16 +194,14 @@ checksums:
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
||||
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/failed_to_parse_csv: 7a3d675ecbb3d15884faf1006a5752d6
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
@@ -219,7 +214,6 @@ checksums:
|
||||
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
||||
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
||||
common/id: c8886d38aeea2ed5f785aba4fc96784b
|
||||
common/image: 048ba7a239de0fbd883ade8558415830
|
||||
common/images: 9305827c28694866f49db42b4c51831f
|
||||
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
@@ -237,7 +231,6 @@ checksums:
|
||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
common/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
||||
common/license_expired: 7af13535e320e4197989472c01387d2c
|
||||
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
||||
@@ -252,6 +245,7 @@ checksums:
|
||||
common/look_and_feel: 9125503712626d495cedec7a79f1418c
|
||||
common/manage: a3d40c0267b81ae53c9598eaeb05087d
|
||||
common/marketing: fcf0f06f8b64b458c7ca6d95541a3cc8
|
||||
common/member: 1606dc30b369856b9dba1fe9aec425d2
|
||||
common/members: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
common/members_and_teams: bf5c3fadcb9fc23533ec1532b805ac08
|
||||
common/membership_not_found: 7ac63584af23396aace9992ad919ffd4
|
||||
@@ -263,11 +257,11 @@ checksums:
|
||||
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
||||
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
||||
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
||||
common/my_product: ad022177062f9ef6e9acf33b13e889aa
|
||||
common/name: 9368b5a047572b6051f334af5aa76819
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
common/next: 89ddbcf710eba274963494f312bdc8a9
|
||||
common/no: 8c708225830b06df2d1141c536f2a0d6
|
||||
common/no_background_image_found: 4108a781a9022c65671a826d4e299d5b
|
||||
common/no_code: f602144ab7d28a5b19a446bf74b4dcc4
|
||||
common/no_files_uploaded: c97be829e195a41b2f6b6717b87a232b
|
||||
@@ -341,6 +335,7 @@ checksums:
|
||||
common/response_id: 73375099cc976dc7203b8e27f5f709e0
|
||||
common/responses: 14bb6c69f906d7bbd1359f7ef1bb3c28
|
||||
common/restart: bab6232e89f24e3129f8e48268739d5b
|
||||
common/retry: 6e44d18639560596569a1278f9c83676
|
||||
common/role: 53743bbb6ca938f5b893552e839d067f
|
||||
common/saas: f01686245bcfb35a3590ab56db677bdb
|
||||
common/sales: 38758eb50094cd8190a71fe67be4d647
|
||||
@@ -359,6 +354,8 @@ checksums:
|
||||
common/select_teams: ae5d451929846ae6367562bc671a1af9
|
||||
common/selected: 9f09e059ba20c88ed34e2b4e8e032d56
|
||||
common/selected_questions: beffe92d5272d99a0022f004e6a6ad73
|
||||
common/selection: 25b570dc6339916a7aada2142aca0cd1
|
||||
common/selections: 82f0681bf0208e25d7efedc23c556b8f
|
||||
common/send_test_email: 2fd3ea40199b9589132ac826a5b0f3f5
|
||||
common/session_not_found: e9622df3170dbfd9636403bb0c22295b
|
||||
common/settings: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
@@ -410,7 +407,7 @@ checksums:
|
||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
common/unify: bdb518a1e62f51049ccc4366b909fb0a
|
||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
@@ -446,6 +443,7 @@ checksums:
|
||||
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/yes: ec580fd11a45779b039466f1e35eed2a
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
@@ -619,6 +617,7 @@ checksums:
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
||||
@@ -629,7 +628,6 @@ checksums:
|
||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
||||
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
|
||||
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
||||
@@ -641,7 +639,6 @@ checksums:
|
||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
@@ -656,8 +653,6 @@ checksums:
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
||||
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
|
||||
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
|
||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
@@ -724,12 +719,7 @@ checksums:
|
||||
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
||||
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
||||
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
||||
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
|
||||
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
|
||||
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
|
||||
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
||||
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
|
||||
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
||||
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
||||
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
||||
@@ -1024,7 +1014,7 @@ checksums:
|
||||
environments/settings/general/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
|
||||
environments/settings/general/email_customization_preview_email_text: fa6ae92403cc8f3c35c03e6c94cbde51
|
||||
environments/settings/general/error_deleting_organization_please_try_again: 7f0fe257d4a0b40bff025408a7766706
|
||||
environments/settings/general/from_your_organization: 9ebd6dcd79f7bfad3fea46ed2e3133d2
|
||||
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
|
||||
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
||||
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||
@@ -1179,7 +1169,6 @@ checksums:
|
||||
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
|
||||
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
|
||||
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
|
||||
environments/surveys/edit/add_highlight_border_description: fe548fe03ea10ef5cd9e553d6812b3c2
|
||||
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
|
||||
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
|
||||
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
|
||||
@@ -1196,13 +1185,11 @@ checksums:
|
||||
environments/surveys/edit/adjust_survey_closed_message: ae6f38c9daf08656362bd84459a312fa
|
||||
environments/surveys/edit/adjust_survey_closed_message_description: e906aebd9af6451a2a39c73287927299
|
||||
environments/surveys/edit/adjust_the_theme_in_the: bccdafda8af5871513266f668b55d690
|
||||
environments/surveys/edit/all_are_true: 05d02c5afac857da530b73dcf18dd8e4
|
||||
environments/surveys/edit/all_other_answers_will_continue_to: 9a5d09eea42ff5fd1c18cc58a14dcabd
|
||||
environments/surveys/edit/allow_multi_select: 7b4b83f7a0205e2a0a8971671a69a174
|
||||
environments/surveys/edit/allow_multiple_files: dbd99f9d1026e4f7c5a5d03f71ba379d
|
||||
environments/surveys/edit/allow_users_to_select_more_than_one_image: d683e0b538d1366400292a771f3fbd08
|
||||
environments/surveys/edit/and_launch_surveys_in_your_website_or_app: a3edcdb4aea792a27d90aad1930f001a
|
||||
environments/surveys/edit/any_is_true: 32c9f3998984fd32a2b5bc53f2d97429
|
||||
environments/surveys/edit/animation: 66a18eacfb92fc9fc9db188d2dde4f81
|
||||
environments/surveys/edit/app_survey_description: bdfacfce478e97f70b700a1382dfa687
|
||||
environments/surveys/edit/assign: e80715ab64bf7cf463abb3a9fd1ad516
|
||||
@@ -1380,6 +1367,7 @@ checksums:
|
||||
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
|
||||
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
|
||||
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
|
||||
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
|
||||
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
|
||||
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
|
||||
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
|
||||
@@ -1494,7 +1482,6 @@ checksums:
|
||||
environments/surveys/edit/question_deleted: ecdeb22b81ae2d732656a7742c1eec7b
|
||||
environments/surveys/edit/question_duplicated: 3f02439fd0a8b818bc84c1b1b473898c
|
||||
environments/surveys/edit/question_id_updated: e8d94dbefcbad00c7464b3d1fb0ee81a
|
||||
environments/surveys/edit/question_number: 742636e9d2d5dcc7ee6ca1b3016bcee7
|
||||
environments/surveys/edit/question_used_in_logic_warning_text: ec78767a7cf335222d41b98cb5baa6be
|
||||
environments/surveys/edit/question_used_in_logic_warning_title: 4bb8528cdc3b8649c194487067737f6d
|
||||
environments/surveys/edit/question_used_in_quota: ceb5e88f6916e4863e589c6be030bb3b
|
||||
@@ -1551,7 +1538,7 @@ checksums:
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||
@@ -1597,7 +1584,6 @@ checksums:
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
||||
@@ -1684,9 +1670,9 @@ checksums:
|
||||
environments/surveys/edit/waiting_time_across_surveys: 6873c18d51830e2cadef67cce6a2c95c
|
||||
environments/surveys/edit/waiting_time_across_surveys_description: 6edafaeb3ccd8cadde81175776636c8e
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
environments/surveys/edit/when: a40ad3eed1b75e76226290eeb9bb20cd
|
||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations: 04241177ba989ef4c1d8c01e1a7b8541
|
||||
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
|
||||
environments/surveys/edit/your_question_here_recall_information_with: 6395bd54f5167830c9d662ba403da167
|
||||
environments/surveys/edit/your_web_app: 07234bed03a33330dc50ae9fcf0174f3
|
||||
@@ -1868,7 +1854,6 @@ checksums:
|
||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
|
||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
@@ -1909,7 +1894,6 @@ checksums:
|
||||
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
||||
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
||||
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
||||
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
@@ -1932,7 +1916,6 @@ checksums:
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
|
||||
environments/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
|
||||
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
|
||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
@@ -1953,6 +1936,111 @@ checksums:
|
||||
environments/surveys/templates/multiple_industries: 7dcb6f6d87feb08f8004dfb5a91e711f
|
||||
environments/surveys/templates/use_this_template: 69020c8b5a521b8f027616bb5c4b64dd
|
||||
environments/surveys/templates/uses_branching_logic: 7ac087d7067d342c17809d4ce497dfe0
|
||||
environments/unify/add_feedback_source: d046fb437ac478ca30b7b59d6afa8e45
|
||||
environments/unify/add_source: 4cc055cbd6312cf0a5db1edf537ce65e
|
||||
environments/unify/allowed_values: 430e0721aa2c52745ef8f8b6918bb7d2
|
||||
environments/unify/change_file: c5163ac18bf443370228a8ecbb0b07da
|
||||
environments/unify/click_load_sample_csv: 0ee0bf93f10f02863fc658b359706316
|
||||
environments/unify/click_to_upload: 74a7e7d79a88b6bbfd9f22084bffdb9b
|
||||
environments/unify/collected_at: b41902ddb4586ba4a4611d726b5014aa
|
||||
environments/unify/configure_import: 71d550661f7e9fe322b60e7e870aa2fd
|
||||
environments/unify/configure_mapping: c794411c50bc511f8fc332def0e4e2f9
|
||||
environments/unify/connection: 421e709602c92ffbe04a266f6a092089
|
||||
environments/unify/connector_created_successfully: ea927316021fb2a41cc69ca3ec89d0aa
|
||||
environments/unify/connector_deleted_successfully: ea3c9842c5b8f75b02ecb9c80c74d780
|
||||
environments/unify/connector_duplicated_successfully: eb21ce42cdbef5fa38244206bf65fe4e
|
||||
environments/unify/connector_status_updated_successfully: 443fd63b27f15a81ff146375adac739f
|
||||
environments/unify/connector_updated_successfully: 11308c4a2881345209cefa06a3d90eab
|
||||
environments/unify/create_mapping: cbe8c951e7819f574ca7d793920b2b60
|
||||
environments/unify/created_by: 6775c2fa7d495fea48f1ad816daea93b
|
||||
environments/unify/csv_at_least_one_row: 165bbc1853dde85c44eb5a587c52ce28
|
||||
environments/unify/csv_columns: 280c5ba0b19ae5fa6d42f4d05a1771cb
|
||||
environments/unify/csv_empty_column_headers: 6e9af154be54778cfca32296fbd23ecb
|
||||
environments/unify/csv_file_too_large: e94c7a7c26096aae9eddb2db30c5cfc1
|
||||
environments/unify/csv_files_only: 920612b537521b14c154f1ac9843e947
|
||||
environments/unify/csv_import: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
environments/unify/csv_import_complete: e8b6306e62e10c128f6464176ba879dd
|
||||
environments/unify/csv_import_duplicate_warning: 56625e4613b93690e95661e5faaa4b27
|
||||
environments/unify/csv_inconsistent_columns: b308be183a41a581707eb5c4c0797ad6
|
||||
environments/unify/csv_max_records: 21ce7adae30821d40a553bcf37f39bbf
|
||||
environments/unify/default_connector_name_csv: ef4060fef24c4fec064987b9d2a9fa4b
|
||||
environments/unify/default_connector_name_formbricks: e7afdf7cc1cd7bcf75e7b5d64903a110
|
||||
environments/unify/deselect_all: facf8871b2e84a454c6bfe40c2821922
|
||||
environments/unify/drop_a_field_here: 884f3025e618e0a5dcbcb5567335d1bb
|
||||
environments/unify/drop_field_or: 5287a8af30f2961ce5a8f14f73ddc353
|
||||
environments/unify/edit_csv_mapping: 4f3bad444664d58ffe8ace3dc9e200f9
|
||||
environments/unify/edit_source_connection: eb42476becc8de3de4ca9626828573f0
|
||||
environments/unify/enter_name_for_source: de6d02a0a8ccc99204ad831ca6dcdbd3
|
||||
environments/unify/enter_value: 4f068bb59617975c1e546218373122cd
|
||||
environments/unify/enum: 96fc644f35edd6b1c09d1d503f078acc
|
||||
environments/unify/failed_to_load_feedback_records: 57f6c8c5fa524d7c2d8777315e5036c8
|
||||
environments/unify/feedback_date: ddba5d3270d4a6394d29721025a04400
|
||||
environments/unify/feedback_record_fields: 88c0f13afeb88fe751f85e79b0f73064
|
||||
environments/unify/feedback_records: e24cf48bb6985910f4ffe5e00512d388
|
||||
environments/unify/feedback_records_refreshed: 4b27a8e2a8dbe8afa945d9f874aa7ef1
|
||||
environments/unify/field_label: 6384505ca0e40010c666b712511132a6
|
||||
environments/unify/field_type: 2581066dc304c853a4a817c20996fa08
|
||||
environments/unify/formbricks_surveys: eba2fce04ee68f02626e5509adf7d66a
|
||||
environments/unify/historical_import_complete: f46f98bf4db63bf2993bfb234dc95f62
|
||||
environments/unify/import_csv_data: f05e1d1ed88d528256efe5702df46646
|
||||
environments/unify/import_feedback: f05e1d1ed88d528256efe5702df46646
|
||||
environments/unify/import_rows: d2963498a7d2766264c4d67db677e8ff
|
||||
environments/unify/importing_data: a6d4478379a0faee05cd2c10ffe74984
|
||||
environments/unify/importing_historical_data: f5be578704ec26dc4ec573309e9fff20
|
||||
environments/unify/invalid_enum_values: e6ca8740dab72f64e8dc5780b5cffcc6
|
||||
environments/unify/invalid_values_found: 5011dc9c0294a222033f9910ea919b8a
|
||||
environments/unify/load_more: 365c2d8dfc53ac7e9188acd5274e2837
|
||||
environments/unify/load_sample_csv: ad21fa63f4a3df96a5939c753be21f4e
|
||||
environments/unify/n_supported_questions: d75413d386441b5eb137a1ea191e4bd9
|
||||
environments/unify/no_feedback_records: 16a905c40f6d47a5e8f93b3d8c6f6693
|
||||
environments/unify/no_source_fields_loaded: a597b1d16262cbe897001046eb3ff640
|
||||
environments/unify/no_sources_connected: 0e8a5612530bfc82091091f40f95012f
|
||||
environments/unify/no_surveys_found: 649a2f29b4c34525778d9177605fb326
|
||||
environments/unify/optional: 396fb9a0472daf401c392bdc3e248943
|
||||
environments/unify/or_drag_and_drop: 6c7d6b05d39dcbfc710d35fcab25cb8c
|
||||
environments/unify/question_selected: b9ff13b6212874258da911867932dc7d
|
||||
environments/unify/question_type_not_supported: 8d9f7554e3b509dfd5307d8d1fef08d7
|
||||
environments/unify/questions_selected: 1f13d6fecafa2ce5ea9e6d07078a1d38
|
||||
environments/unify/refresh_feedback_records: c111751e02a7dee57390ed7fb79cfcc6
|
||||
environments/unify/refreshing_feedback_records: 2a03b44510ebe19eea6473639e9a7222
|
||||
environments/unify/required: 04d7fb6f37ffe0a6ca97d49e2a8b6eb5
|
||||
environments/unify/save_changes: 53dd9f4f0a4accc822fa5c1f2f6d118a
|
||||
environments/unify/select_a_survey_to_see_questions: 792eba3d2f6d210231a2266401111a20
|
||||
environments/unify/select_a_value: 115002bf2d9eec536165a7b7efc62862
|
||||
environments/unify/select_all: eedc7cdb02de467c15dc418a066a77f2
|
||||
environments/unify/select_questions: 13c79b8c284423eb6140534bf2137e56
|
||||
environments/unify/select_source_type_description: fd7e3c49b81f8e89f294c8fd94efcdfc
|
||||
environments/unify/select_source_type_prompt: c3fce7d908ee62b9e1b7fab1b17606d7
|
||||
environments/unify/select_survey: bac52e59c7847417bef6fe7b7096b475
|
||||
environments/unify/select_survey_and_questions: 53914988a2f48caecea23f3b3b868b9f
|
||||
environments/unify/select_survey_questions_description: 3386ed56085eabebefa3cc453269fc5b
|
||||
environments/unify/set_value: b8a86f8da957ebd599ece4b1b1936a78
|
||||
environments/unify/setup_connection: cce7d9c488d737d04e70bed929a46f8a
|
||||
environments/unify/showing_count: 20675071b78443b250ab13b11138f30d
|
||||
environments/unify/showing_rows: 83d3440314d1e6f2721e034369a3a131
|
||||
environments/unify/source: 45309626f464f4bda161ee783a4c8c80
|
||||
environments/unify/source_connect_csv_description: 2f9d1dd31668ac52578f16323157b746
|
||||
environments/unify/source_connect_formbricks_description: 77bda4e1d485d76770ba2221f1faf9ff
|
||||
environments/unify/source_fields: 1bae074990e64cbfd820a0b6462397be
|
||||
environments/unify/source_name: 157675beca12efcd8ec512c5256b1a61
|
||||
environments/unify/source_type: d1ff69af76c687eb189db72030717570
|
||||
environments/unify/source_type_cannot_be_changed: bb5232c6e92df7f88731310fabbb1eb1
|
||||
environments/unify/sources: ecbbe6e49baa335c5afd7b04b609d006
|
||||
environments/unify/status_active: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
environments/unify/status_completed: 0e4bbce9985f25eb673d9a054c8d5334
|
||||
environments/unify/status_draft: e8a92958ad300aacfe46c2bf6644927e
|
||||
environments/unify/status_error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
environments/unify/status_paused: edb1f7b7219e1c9b7aa67159090d6991
|
||||
environments/unify/survey_has_no_questions: c08514b6bce5eb464a4492239be5934d
|
||||
environments/unify/survey_import_line: 63fa0ea1d7daa3ba333436fbc65f8b19
|
||||
environments/unify/total_feedback_records: 8962087650b62e4a12b81e7d09317ffa
|
||||
environments/unify/unify_feedback: cd68c8ce0445767e7dcfb4de789903d5
|
||||
environments/unify/update_mapping_description: 58d5966c0c9b406c037dff3aa8bcb396
|
||||
environments/unify/updated_at: 8fdb85248e591254973403755dcc3724
|
||||
environments/unify/upload_csv_data_description: 7fab46222ab05a4424db90a7cc96cdf5
|
||||
environments/unify/upload_csv_file: b77797b68cb46a614b3adaa4db24d4c2
|
||||
environments/unify/user_identifier: 61073457a5c3901084b557d065f876be
|
||||
environments/unify/value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/workspace/api_keys/add_api_key: 3c7633bae18a6e19af7a5af12f9bc3da
|
||||
environments/workspace/api_keys/api_key: ce825fec5b3e1f8e27c45b1a63619985
|
||||
environments/workspace/api_keys/api_key_copied_to_clipboard: daeeac786ba09ffa650e206609b88f9c
|
||||
@@ -2052,7 +2140,7 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_description_size: a0d51c3ab7dc56320ecedc2b27917842
|
||||
environments/workspace/look/advanced_styling_field_description_size_description: ff880ea1beddd1b1ec7416d0b8a69cf3
|
||||
environments/workspace/look/advanced_styling_field_description_weight: 514680cc7202ad29835c1cbcde3def1c
|
||||
environments/workspace/look/advanced_styling_field_description_weight_description: aa95bc81b5336a548e256bce49350683
|
||||
environments/workspace/look/advanced_styling_field_description_weight_description: 441ac8db1a32557813eb68fbfd759061
|
||||
environments/workspace/look/advanced_styling_field_font_size: ca44d14429b2175a1b194793b4ab8f6b
|
||||
environments/workspace/look/advanced_styling_field_font_weight: bfef83778146cf40550df9650d8a07da
|
||||
environments/workspace/look/advanced_styling_field_headline_color: 4ccf3935ad90c88ad4add24f498673ce
|
||||
@@ -2066,7 +2154,7 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
||||
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: bb7439d42ec3848a8fa9edb8b001b69a
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: e19ec0dc432478def0fd1199ad765e38
|
||||
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||
@@ -2075,8 +2163,6 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
|
||||
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
|
||||
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
|
||||
environments/workspace/look/advanced_styling_field_option_border: aa478eb148515b6a2637fb144ff72028
|
||||
environments/workspace/look/advanced_styling_field_option_border_description: 8f75b740e8dcb7f6cfeff2e5d5ca7c92
|
||||
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
|
||||
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
|
||||
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
|
||||
@@ -2248,16 +2334,6 @@ checksums:
|
||||
templates/alignment_and_engagement_survey_question_4_headline: e36be56ce8aad1d0ca04939bea4e39b7
|
||||
templates/alignment_and_engagement_survey_question_4_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/back: f541015a827e37cb3b1234e56bc2aa3c
|
||||
templates/block_1: 5e1b4dce0cb70662441b663507a69454
|
||||
templates/block_2: f50d8aab8b44f168a2ab00526d4f9a2c
|
||||
templates/block_3: 78d84f8e4763a95710543c5368ce8a41
|
||||
templates/block_4: 2c346374f245a6821940c061b855ac69
|
||||
templates/block_5: 975abfc66e8e377478ff691a040dda0b
|
||||
templates/block_6: 2bd10f1edb210243c5ab459c59e02d30
|
||||
templates/block_7: 13f0f680c09c96081e125123ad2f6786
|
||||
templates/block_8: 1be1b18e159e8c8d11d2fb1082ea5d98
|
||||
templates/block_9: 2da3894d05e4415fa043ba18d11d60e2
|
||||
templates/block_10: 09a42e99b34b45700e734730acfe37ed
|
||||
templates/book_interview: 1cc9c72d1c088b28e5dfa5ec7d7b78c4
|
||||
templates/build_product_roadmap_description: 6ca163ed3b0095cedcbc11822a0d502a
|
||||
templates/build_product_roadmap_name: 8c216b183c3539c0340ce87465a391cc
|
||||
@@ -2465,6 +2541,7 @@ checksums:
|
||||
templates/csat_survey_question_3_headline: 25974b7f1692cad41908fe305830b6c0
|
||||
templates/csat_survey_question_3_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/cta_description: bc94a2ddc965b286a8677b0642696c7e
|
||||
templates/custom_survey_block_1_name: 5e1b4dce0cb70662441b663507a69454
|
||||
templates/custom_survey_description: 0492afdea2ef1bd683eaf48a2bad2caa
|
||||
templates/custom_survey_name: 6fc756927ca9ea22c26368cccd64a67e
|
||||
templates/custom_survey_question_1_headline: 0abf9d41e0b5c5567c3833fd63048398
|
||||
@@ -2867,9 +2944,6 @@ checksums:
|
||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
|
||||
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
|
||||
|
||||
525
apps/web/lib/connector/actions.ts
Normal file
525
apps/web/lib/connector/actions.ts
Normal file
@@ -0,0 +1,525 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TConnectorWithMappings,
|
||||
THubFieldType,
|
||||
ZConnectorCreateInput,
|
||||
ZConnectorFieldMappingCreateInput,
|
||||
ZConnectorUpdateInput,
|
||||
getHubFieldTypeFromElementType,
|
||||
} from "@formbricks/types/connector";
|
||||
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { AuthenticatedActionClientCtx } from "@/lib/utils/action-client/types/context";
|
||||
import {
|
||||
getOrganizationIdFromConnectorId,
|
||||
getOrganizationIdFromEnvironmentId,
|
||||
getOrganizationIdFromSurveyId,
|
||||
getProjectIdFromConnectorId,
|
||||
getProjectIdFromEnvironmentId,
|
||||
} from "@/lib/utils/helper";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
import { listFeedbackRecords } from "@/modules/hub/service";
|
||||
import type { FeedbackRecordListParams, FeedbackRecordListResponse } from "@/modules/hub/types";
|
||||
import { importCsvData } from "./csv-import";
|
||||
import { importHistoricalResponses } from "./import";
|
||||
import {
|
||||
TMappingsInput,
|
||||
createConnectorWithMappings,
|
||||
deleteConnector,
|
||||
getConnectorWithMappingsById,
|
||||
updateConnector,
|
||||
updateConnectorWithMappings,
|
||||
} from "./service";
|
||||
|
||||
const ZDeleteConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const deleteConnectorAction = authenticatedActionClient
|
||||
.schema(ZDeleteConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDeleteConnectorAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return deleteConnector(parsedInput.connectorId, parsedInput.environmentId);
|
||||
}
|
||||
);
|
||||
|
||||
const resolveSurveyMappings = async (
|
||||
surveyId: string,
|
||||
elementIds: string[]
|
||||
): Promise<{ surveyId: string; elementId: string; hubFieldType: THubFieldType }[]> => {
|
||||
const survey = await getSurvey(surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", surveyId);
|
||||
}
|
||||
|
||||
const elements = getElementsFromBlocks(survey.blocks);
|
||||
const elementMap = new Map(elements.map((el) => [el.id, el]));
|
||||
|
||||
return elementIds
|
||||
.filter((elementId) => {
|
||||
if (elementMap.has(elementId)) return true;
|
||||
logger.warn({ surveyId, elementId }, "Skipping unknown elementId when building connector mappings");
|
||||
return false;
|
||||
})
|
||||
.map((elementId) => {
|
||||
const element = elementMap.get(elementId)!;
|
||||
return {
|
||||
surveyId,
|
||||
elementId,
|
||||
hubFieldType: getHubFieldTypeFromElementType(element.type),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const resolveFormbricksMappingsInput = async (
|
||||
entries: { surveyId: string; elementIds: string[] }[]
|
||||
): Promise<TMappingsInput> => {
|
||||
const allMappings = await Promise.all(
|
||||
entries.map(({ surveyId, elementIds }) => resolveSurveyMappings(surveyId, elementIds))
|
||||
);
|
||||
return { type: "formbricks", mappings: allMappings.flat() };
|
||||
};
|
||||
|
||||
const ZFormbricksSurveyMapping = z.object({
|
||||
surveyId: ZId,
|
||||
elementIds: z.array(z.string()).min(1),
|
||||
});
|
||||
|
||||
const ZCreateConnectorWithMappingsAction = z
|
||||
.object({
|
||||
environmentId: ZId,
|
||||
connectorInput: ZConnectorCreateInput,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.connectorInput.type === "formbricks") {
|
||||
if (!data.formbricksMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["formbricksMappings"],
|
||||
message: "At least one survey mapping is required for Formbricks connectors",
|
||||
});
|
||||
}
|
||||
} else if (data.connectorInput.type === "csv") {
|
||||
if (!data.fieldMappings?.length) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["fieldMappings"],
|
||||
message: "At least one field mapping is required for CSV connectors",
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export const createConnectorWithMappingsAction = authenticatedActionClient
|
||||
.schema(ZCreateConnectorWithMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZCreateConnectorWithMappingsAction>;
|
||||
}): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
const { formbricksMappings, fieldMappings } = parsedInput;
|
||||
|
||||
if (formbricksMappings?.length) {
|
||||
await Promise.all(
|
||||
formbricksMappings.map(async ({ surveyId }) => {
|
||||
const orgId = await getOrganizationIdFromSurveyId(surveyId);
|
||||
if (orgId !== organizationId) {
|
||||
throw new AuthorizationError("You are not authorized to access this survey");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(formbricksMappings);
|
||||
} else if (fieldMappings?.length) {
|
||||
mappingsInput = { type: "field", mappings: fieldMappings };
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.environmentId,
|
||||
{ ...parsedInput.connectorInput, createdBy: ctx.user.id },
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZUpdateConnectorWithMappingsAction = z.object({
|
||||
connectorId: ZId,
|
||||
environmentId: ZId,
|
||||
connectorInput: ZConnectorUpdateInput,
|
||||
formbricksMappings: z.array(ZFormbricksSurveyMapping).min(1).optional(),
|
||||
fieldMappings: z.array(ZConnectorFieldMappingCreateInput).optional(),
|
||||
});
|
||||
|
||||
export const updateConnectorWithMappingsAction = authenticatedActionClient
|
||||
.schema(ZUpdateConnectorWithMappingsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZUpdateConnectorWithMappingsAction>;
|
||||
}): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (parsedInput.formbricksMappings?.length) {
|
||||
await Promise.all(
|
||||
parsedInput.formbricksMappings.map(async ({ surveyId }) => {
|
||||
const orgId = await getOrganizationIdFromSurveyId(surveyId);
|
||||
if (orgId !== organizationId) {
|
||||
throw new AuthorizationError("You are not authorized to access this survey");
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
mappingsInput = await resolveFormbricksMappingsInput(parsedInput.formbricksMappings);
|
||||
} else if (parsedInput.fieldMappings && parsedInput.fieldMappings.length > 0) {
|
||||
mappingsInput = { type: "field", mappings: parsedInput.fieldMappings };
|
||||
}
|
||||
|
||||
return updateConnectorWithMappings(
|
||||
parsedInput.connectorId,
|
||||
parsedInput.environmentId,
|
||||
parsedInput.connectorInput,
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZDuplicateConnectorAction = z.object({
|
||||
connectorId: ZId,
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const duplicateConnectorAction = authenticatedActionClient
|
||||
.schema(ZDuplicateConnectorAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZDuplicateConnectorAction>;
|
||||
}): Promise<TConnectorWithMappings> => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const source = await getConnectorWithMappingsById(parsedInput.connectorId, parsedInput.environmentId);
|
||||
if (!source) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
let mappingsInput: TMappingsInput | undefined;
|
||||
|
||||
if (source.type === "formbricks" && source.formbricksMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "formbricks",
|
||||
mappings: source.formbricksMappings.map((m) => ({
|
||||
surveyId: m.surveyId,
|
||||
elementId: m.elementId,
|
||||
hubFieldType: m.hubFieldType,
|
||||
customFieldLabel: m.customFieldLabel ?? undefined,
|
||||
})),
|
||||
};
|
||||
} else if (source.fieldMappings.length > 0) {
|
||||
mappingsInput = {
|
||||
type: "field",
|
||||
mappings: source.fieldMappings.map((m) => ({
|
||||
sourceFieldId: m.sourceFieldId,
|
||||
targetFieldId: m.targetFieldId,
|
||||
staticValue: m.staticValue ?? undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
return createConnectorWithMappings(
|
||||
parsedInput.environmentId,
|
||||
{ name: `${source.name} (copy)`, type: source.type, createdBy: ctx.user.id },
|
||||
mappingsInput
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const ZGetResponseCountAction = z.object({
|
||||
surveyId: ZId,
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const getResponseCountAction = authenticatedActionClient
|
||||
.schema(ZGetResponseCountAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZGetResponseCountAction>;
|
||||
}): Promise<number> => {
|
||||
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId);
|
||||
}
|
||||
);
|
||||
|
||||
const ZImportHistoricalResponsesAction = z.object({
|
||||
connectorId: ZId,
|
||||
environmentId: ZId,
|
||||
surveyId: ZId,
|
||||
});
|
||||
|
||||
export const importHistoricalResponsesAction = authenticatedActionClient
|
||||
.schema(ZImportHistoricalResponsesAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZImportHistoricalResponsesAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const connector = await getConnectorWithMappingsById(
|
||||
parsedInput.connectorId,
|
||||
parsedInput.environmentId
|
||||
);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
const survey = await getSurvey(parsedInput.surveyId);
|
||||
if (!survey) {
|
||||
throw new ResourceNotFoundError("Survey", parsedInput.surveyId);
|
||||
}
|
||||
|
||||
return importHistoricalResponses(connector, survey);
|
||||
}
|
||||
);
|
||||
|
||||
const ZImportCsvDataAction = z.object({
|
||||
connectorId: ZId,
|
||||
environmentId: ZId,
|
||||
csvData: z.array(z.record(z.string())).min(1),
|
||||
});
|
||||
|
||||
export const importCsvDataAction = authenticatedActionClient
|
||||
.schema(ZImportCsvDataAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZImportCsvDataAction>;
|
||||
}) => {
|
||||
const organizationId = await getOrganizationIdFromConnectorId(parsedInput.connectorId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "readWrite",
|
||||
projectId: await getProjectIdFromConnectorId(parsedInput.connectorId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const connector = await getConnectorWithMappingsById(
|
||||
parsedInput.connectorId,
|
||||
parsedInput.environmentId
|
||||
);
|
||||
if (!connector) {
|
||||
throw new ResourceNotFoundError("Connector", parsedInput.connectorId);
|
||||
}
|
||||
|
||||
const result = await importCsvData(connector, parsedInput.csvData);
|
||||
|
||||
if (result.successes > 0) {
|
||||
await updateConnector(parsedInput.connectorId, parsedInput.environmentId, {
|
||||
lastSyncAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
|
||||
const ZListFeedbackRecordsAction = z.object({
|
||||
environmentId: ZId,
|
||||
limit: z.number().min(1).max(1000).optional(),
|
||||
offset: z.number().min(0).optional(),
|
||||
sourceType: z.string().optional(),
|
||||
fieldType: z.string().optional(),
|
||||
since: z.string().optional(),
|
||||
until: z.string().optional(),
|
||||
});
|
||||
|
||||
export const listFeedbackRecordsAction = authenticatedActionClient
|
||||
.schema(ZListFeedbackRecordsAction)
|
||||
.action(
|
||||
async ({
|
||||
ctx,
|
||||
parsedInput,
|
||||
}: {
|
||||
ctx: AuthenticatedActionClientCtx;
|
||||
parsedInput: z.infer<typeof ZListFeedbackRecordsAction>;
|
||||
}): Promise<FeedbackRecordListResponse> => {
|
||||
const organizationId = await getOrganizationIdFromEnvironmentId(parsedInput.environmentId);
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId,
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const params: FeedbackRecordListParams = {
|
||||
tenant_id: parsedInput.environmentId,
|
||||
limit: parsedInput.limit ?? 50,
|
||||
offset: parsedInput.offset ?? 0,
|
||||
};
|
||||
if (parsedInput.sourceType) params.source_type = parsedInput.sourceType;
|
||||
if (parsedInput.fieldType) params.field_type = parsedInput.fieldType;
|
||||
if (parsedInput.since) params.since = parsedInput.since;
|
||||
if (parsedInput.until) params.until = parsedInput.until;
|
||||
|
||||
const result = await listFeedbackRecords(params);
|
||||
if (result.error || !result.data) {
|
||||
logger.warn({ error: result.error }, "Failed to list feedback records");
|
||||
const t = await getTranslate();
|
||||
throw new Error(result.error?.message ?? t("environments.unify.failed_to_load_feedback_records"));
|
||||
}
|
||||
|
||||
return result.data;
|
||||
}
|
||||
);
|
||||
122
apps/web/lib/connector/csv-import.test.ts
Normal file
122
apps/web/lib/connector/csv-import.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { importCsvData } from "./csv-import";
|
||||
|
||||
vi.mock("@/modules/hub", () => ({
|
||||
createFeedbackRecordsBatch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./csv-transform", () => ({
|
||||
transformCsvRowsToFeedbackRecords: vi.fn(),
|
||||
}));
|
||||
|
||||
const { createFeedbackRecordsBatch } = vi.mocked(await import("@/modules/hub"));
|
||||
const { transformCsvRowsToFeedbackRecords } = vi.mocked(await import("./csv-transform"));
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const makeConnector = (overrides?: Partial<TConnectorWithMappings>): TConnectorWithMappings => ({
|
||||
id: "conn-1",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
name: "CSV Import",
|
||||
type: "csv",
|
||||
status: "active",
|
||||
environmentId: "env-1",
|
||||
lastSyncAt: null,
|
||||
createdBy: null,
|
||||
creatorName: null,
|
||||
formbricksMappings: [],
|
||||
fieldMappings: [
|
||||
{
|
||||
id: "fm-1",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
environmentId: "env-1",
|
||||
sourceFieldId: "feedback",
|
||||
targetFieldId: "value_text",
|
||||
staticValue: null,
|
||||
},
|
||||
{
|
||||
id: "fm-2",
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
environmentId: "env-1",
|
||||
sourceFieldId: "",
|
||||
targetFieldId: "source_type",
|
||||
staticValue: "csv",
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("importCsvData", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("throws InvalidInputError for non-csv connector", async () => {
|
||||
const connector = makeConnector({ type: "formbricks" });
|
||||
await expect(importCsvData(connector, [])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("throws InvalidInputError when no field mappings configured", async () => {
|
||||
const connector = makeConnector({ fieldMappings: [] });
|
||||
await expect(importCsvData(connector, [{ feedback: "test" }])).rejects.toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
test("returns zeros when all rows are skipped", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records: [], skipped: 3 });
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{ a: "1" }, { a: "2" }, { a: "3" }]);
|
||||
|
||||
expect(result).toEqual({ successes: 0, failures: 0, skipped: 3 });
|
||||
expect(createFeedbackRecordsBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("sends transformed records to Hub and counts results", async () => {
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({
|
||||
records: [
|
||||
{ source_type: "csv", field_id: "q1", field_type: "text" as const, value_text: "Good" },
|
||||
{ source_type: "csv", field_id: "q2", field_type: "text" as const, value_text: "Bad" },
|
||||
],
|
||||
skipped: 1,
|
||||
});
|
||||
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [
|
||||
{ data: { id: "fb1" }, error: null },
|
||||
{ data: null, error: { status: 400, message: "Bad request", detail: null } },
|
||||
],
|
||||
} as never);
|
||||
|
||||
const result = await importCsvData(makeConnector(), [{}, {}, {}]);
|
||||
|
||||
expect(result).toEqual({ successes: 1, failures: 1, skipped: 1 });
|
||||
});
|
||||
|
||||
test("processes records in batches of 50", async () => {
|
||||
const records = Array.from({ length: 120 }, (_, i) => ({
|
||||
source_type: "csv",
|
||||
field_id: `q${i}`,
|
||||
field_type: "text" as const,
|
||||
value_text: `row ${i}`,
|
||||
}));
|
||||
|
||||
transformCsvRowsToFeedbackRecords.mockReturnValue({ records, skipped: 0 });
|
||||
createFeedbackRecordsBatch.mockResolvedValue({
|
||||
results: [{ data: { id: "fb" }, error: null }],
|
||||
} as never);
|
||||
|
||||
await importCsvData(
|
||||
makeConnector(),
|
||||
Array.from({ length: 120 }, () => ({}))
|
||||
);
|
||||
|
||||
expect(createFeedbackRecordsBatch).toHaveBeenCalledTimes(3);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[0][0]).toHaveLength(50);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[1][0]).toHaveLength(50);
|
||||
expect(createFeedbackRecordsBatch.mock.calls[2][0]).toHaveLength(20);
|
||||
});
|
||||
});
|
||||
39
apps/web/lib/connector/csv-import.ts
Normal file
39
apps/web/lib/connector/csv-import.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import "server-only";
|
||||
import { TConnectorWithMappings } from "@formbricks/types/connector";
|
||||
import { InvalidInputError } from "@formbricks/types/errors";
|
||||
import { createFeedbackRecordsBatch } from "@/modules/hub";
|
||||
import { transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
import { TImportResult } from "./import";
|
||||
|
||||
const CSV_BATCH_SIZE = 50;
|
||||
|
||||
export const importCsvData = async (
|
||||
connector: TConnectorWithMappings,
|
||||
csvRows: Record<string, string>[]
|
||||
): Promise<TImportResult> => {
|
||||
if (connector.type !== "csv") {
|
||||
throw new InvalidInputError("CSV import is only supported for CSV connectors");
|
||||
}
|
||||
|
||||
if (connector.fieldMappings.length === 0) {
|
||||
throw new InvalidInputError("Connector has no field mappings configured");
|
||||
}
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(
|
||||
csvRows,
|
||||
connector.fieldMappings,
|
||||
connector.environmentId
|
||||
);
|
||||
|
||||
let successes = 0;
|
||||
let failures = 0;
|
||||
|
||||
for (let i = 0; i < records.length; i += CSV_BATCH_SIZE) {
|
||||
const batch = records.slice(i, i + CSV_BATCH_SIZE);
|
||||
const { results } = await createFeedbackRecordsBatch(batch);
|
||||
successes += results.filter((r) => r.data !== null).length;
|
||||
failures += results.filter((r) => r.error !== null).length;
|
||||
}
|
||||
|
||||
return { successes, failures, skipped };
|
||||
};
|
||||
214
apps/web/lib/connector/csv-transform.test.ts
Normal file
214
apps/web/lib/connector/csv-transform.test.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TConnectorFieldMapping } from "@formbricks/types/connector";
|
||||
import { transformCsvRowToFeedbackRecord, transformCsvRowsToFeedbackRecords } from "./csv-transform";
|
||||
|
||||
const NOW = new Date("2026-02-25T10:00:00.000Z");
|
||||
|
||||
const makeMapping = (
|
||||
sourceFieldId: string,
|
||||
targetFieldId: string,
|
||||
staticValue?: string
|
||||
): TConnectorFieldMapping => ({
|
||||
id: `mapping-${targetFieldId}`,
|
||||
createdAt: NOW,
|
||||
connectorId: "conn-1",
|
||||
environmentId: "env-1",
|
||||
sourceFieldId,
|
||||
targetFieldId: targetFieldId as TConnectorFieldMapping["targetFieldId"],
|
||||
staticValue: staticValue ?? null,
|
||||
});
|
||||
|
||||
const baseMappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
describe("transformCsvRowToFeedbackRecord", () => {
|
||||
test("transforms a basic row with all required fields", () => {
|
||||
const row = {
|
||||
feedback_text: "Great product!",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.source_type).toBe("survey");
|
||||
expect(result!.field_id).toBe("q1");
|
||||
expect(result!.field_type).toBe("text");
|
||||
expect(result!.value_text).toBe("Great product!");
|
||||
expect(result!.collected_at).toBe("2026-01-15T10:00:00.000Z");
|
||||
});
|
||||
|
||||
test("returns null when required fields are missing", () => {
|
||||
const row = { feedback_text: "Great product!" };
|
||||
const mappings = [makeMapping("feedback_text", "value_text")];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("coerces value_number from string", () => {
|
||||
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "4.5",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.value_number).toBe(4.5);
|
||||
});
|
||||
|
||||
test("skips value_number when not a valid number", () => {
|
||||
const mappings = [...baseMappings, makeMapping("rating", "value_number")];
|
||||
const row = {
|
||||
feedback_text: "Good",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
rating: "not-a-number",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.value_number).toBeUndefined();
|
||||
});
|
||||
|
||||
test("coerces value_boolean from string", () => {
|
||||
const mappings = [...baseMappings, makeMapping("is_promoter", "value_boolean")];
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "true" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "0" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
transformCsvRowToFeedbackRecord(
|
||||
{ feedback_text: "x", question: "q1", timestamp: "2026-01-15", is_promoter: "yes" },
|
||||
mappings
|
||||
)!.value_boolean
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("handles $now static value for collected_at", () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(NOW);
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "csv"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("", "collected_at", "$now"),
|
||||
];
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord({ question: "q1" }, mappings);
|
||||
expect(result!.collected_at).toBe(NOW.toISOString());
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("uses static value over source field", () => {
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("type_column", "source_type", "always_survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const row = { question: "q1", type_column: "review", timestamp: "2026-01-15" };
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.source_type).toBe("always_survey");
|
||||
});
|
||||
|
||||
test("skips empty string values", () => {
|
||||
const row = {
|
||||
feedback_text: "",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15T10:00:00Z",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result!.value_text).toBeUndefined();
|
||||
});
|
||||
|
||||
test("parses metadata as JSON", () => {
|
||||
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: '{"device":"mobile","version":"2.1"}',
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.metadata).toEqual({ device: "mobile", version: "2.1" });
|
||||
});
|
||||
|
||||
test("wraps non-JSON metadata in { raw: value }", () => {
|
||||
const mappings = [...baseMappings, makeMapping("meta", "metadata")];
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "2026-01-15",
|
||||
meta: "just a string",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, mappings);
|
||||
expect(result!.metadata).toEqual({ raw: "just a string" });
|
||||
});
|
||||
|
||||
test("handles invalid date gracefully", () => {
|
||||
const row = {
|
||||
feedback_text: "test",
|
||||
question: "q1",
|
||||
timestamp: "not-a-date",
|
||||
};
|
||||
|
||||
const result = transformCsvRowToFeedbackRecord(row, baseMappings);
|
||||
expect(result!.collected_at).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("transformCsvRowsToFeedbackRecords", () => {
|
||||
test("transforms multiple rows and counts skipped", () => {
|
||||
const rows = [
|
||||
{ feedback_text: "Good", question: "q1", timestamp: "2026-01-15" },
|
||||
{ feedback_text: "Bad", question: "q2", timestamp: "2026-01-16" },
|
||||
{ feedback_text: "No question field" },
|
||||
];
|
||||
|
||||
const mappings: TConnectorFieldMapping[] = [
|
||||
makeMapping("feedback_text", "value_text"),
|
||||
makeMapping("question", "field_id"),
|
||||
makeMapping("", "source_type", "survey"),
|
||||
makeMapping("", "field_type", "text"),
|
||||
makeMapping("timestamp", "collected_at"),
|
||||
];
|
||||
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords(rows, mappings);
|
||||
|
||||
expect(records).toHaveLength(2);
|
||||
expect(skipped).toBe(1);
|
||||
expect(records[0].field_id).toBe("q1");
|
||||
expect(records[1].field_id).toBe("q2");
|
||||
});
|
||||
|
||||
test("returns empty records for empty input", () => {
|
||||
const { records, skipped } = transformCsvRowsToFeedbackRecords([], baseMappings);
|
||||
expect(records).toHaveLength(0);
|
||||
expect(skipped).toBe(0);
|
||||
});
|
||||
});
|
||||
111
apps/web/lib/connector/csv-transform.ts
Normal file
111
apps/web/lib/connector/csv-transform.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { TConnectorFieldMapping, THubTargetField } from "@formbricks/types/connector";
|
||||
import { FeedbackRecordCreateParams } from "@/modules/hub";
|
||||
|
||||
const NUMERIC_FIELDS = new Set<THubTargetField>(["value_number"]);
|
||||
const BOOLEAN_FIELDS = new Set<THubTargetField>(["value_boolean"]);
|
||||
const TIMESTAMP_FIELDS = new Set<THubTargetField>(["collected_at", "value_date"]);
|
||||
const JSON_FIELDS = new Set<THubTargetField>(["metadata"]);
|
||||
|
||||
const coerceValue = (value: string, targetField: THubTargetField): string | number | boolean | undefined => {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "") return undefined;
|
||||
|
||||
if (NUMERIC_FIELDS.has(targetField)) {
|
||||
const parsed = Number.parseFloat(trimmed);
|
||||
return Number.isNaN(parsed) ? undefined : parsed;
|
||||
}
|
||||
|
||||
if (BOOLEAN_FIELDS.has(targetField)) {
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (lower === "true" || lower === "1" || lower === "yes") return true;
|
||||
if (lower === "false" || lower === "0" || lower === "no") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (TIMESTAMP_FIELDS.has(targetField)) {
|
||||
const date = new Date(trimmed);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const resolveValue = (
|
||||
row: Record<string, string>,
|
||||
mapping: TConnectorFieldMapping
|
||||
): string | number | boolean | undefined => {
|
||||
if (mapping.staticValue) {
|
||||
if (mapping.staticValue === "$now" && TIMESTAMP_FIELDS.has(mapping.targetFieldId)) {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
return coerceValue(mapping.staticValue, mapping.targetFieldId);
|
||||
}
|
||||
|
||||
const rawValue = row[mapping.sourceFieldId];
|
||||
if (rawValue === undefined || rawValue === null) return undefined;
|
||||
|
||||
return coerceValue(rawValue, mapping.targetFieldId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform a single CSV row into a FeedbackRecord using field mappings.
|
||||
*
|
||||
* Each mapping maps a CSV column (sourceFieldId) or a static value to a target field.
|
||||
* Returns null if required fields (source_type, field_id, field_type) are missing after mapping.
|
||||
*/
|
||||
export const transformCsvRowToFeedbackRecord = (
|
||||
row: Record<string, string>,
|
||||
mappings: TConnectorFieldMapping[],
|
||||
tenantId?: string
|
||||
): FeedbackRecordCreateParams | null => {
|
||||
const record: Record<string, string | number | boolean | Record<string, unknown> | undefined> = {};
|
||||
|
||||
for (const mapping of mappings) {
|
||||
const value = resolveValue(row, mapping);
|
||||
if (value === undefined) continue;
|
||||
|
||||
if (JSON_FIELDS.has(mapping.targetFieldId)) {
|
||||
try {
|
||||
record[mapping.targetFieldId] = typeof value === "string" ? JSON.parse(value) : value;
|
||||
} catch {
|
||||
record[mapping.targetFieldId] = { raw: value };
|
||||
}
|
||||
} else {
|
||||
record[mapping.targetFieldId] = value;
|
||||
}
|
||||
}
|
||||
|
||||
if (!record.source_type || !record.field_id || !record.field_type) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (tenantId && !record.tenant_id) {
|
||||
record.tenant_id = tenantId;
|
||||
}
|
||||
|
||||
return record as unknown as FeedbackRecordCreateParams;
|
||||
};
|
||||
|
||||
/**
|
||||
* Transform multiple CSV rows into FeedbackRecords.
|
||||
* Returns the successfully transformed records and a count of skipped rows.
|
||||
*/
|
||||
export const transformCsvRowsToFeedbackRecords = (
|
||||
rows: Record<string, string>[],
|
||||
mappings: TConnectorFieldMapping[],
|
||||
tenantId?: string
|
||||
): { records: FeedbackRecordCreateParams[]; skipped: number } => {
|
||||
const records: FeedbackRecordCreateParams[] = [];
|
||||
let skipped = 0;
|
||||
|
||||
for (const row of rows) {
|
||||
const record = transformCsvRowToFeedbackRecord(row, mappings, tenantId);
|
||||
if (record) {
|
||||
records.push(record);
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
return { records, skipped };
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user